ani-auto 1.4.3 → 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 (90) 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 +19 -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 +66 -13
  59. package/src/notify.js +82 -7
  60. package/src/scraper.js +2 -2
  61. package/src/server.js +108 -0
  62. package/utils.js +21 -21
  63. package/web/index.html +13 -0
  64. package/web/package-lock.json +1420 -0
  65. package/web/package.json +24 -0
  66. package/web/src/App.vue +200 -0
  67. package/web/src/api/client.js +75 -0
  68. package/web/src/assets/styles/base.css +961 -0
  69. package/web/src/components/UpdateModal.vue +157 -0
  70. package/web/src/components/sidebar/SidebarResizable.vue +170 -0
  71. package/web/src/components/sidebar/sidebarConfig.js +24 -0
  72. package/web/src/main.js +104 -0
  73. package/web/src/router/index.js +44 -0
  74. package/web/src/stores/config.js +27 -0
  75. package/web/src/stores/download.js +107 -0
  76. package/web/src/stores/update.js +73 -0
  77. package/web/src/views/About.vue +42 -0
  78. package/web/src/views/AniListSync.vue +122 -0
  79. package/web/src/views/AnimeDetail.vue +202 -0
  80. package/web/src/views/AnimeList.vue +110 -0
  81. package/web/src/views/CFResult.vue +312 -0
  82. package/web/src/views/DaemonControl.vue +83 -0
  83. package/web/src/views/Dashboard.vue +187 -0
  84. package/web/src/views/DownloadCenter.vue +153 -0
  85. package/web/src/views/MatchFinder.vue +131 -0
  86. package/web/src/views/OAuthCallback.vue +44 -0
  87. package/web/src/views/Onboarding.vue +93 -0
  88. package/web/src/views/RunHistory.vue +122 -0
  89. package/web/src/views/Settings.vue +410 -0
  90. package/web/vite.config.js +23 -0
@@ -0,0 +1,73 @@
1
+ import { defineStore } from 'pinia'
2
+ import { api } from '../api/client'
3
+
4
+ export const useUpdateStore = defineStore('update', {
5
+ state: () => ({
6
+ checking: false,
7
+ currentVersion: '',
8
+ latestVersion: '',
9
+ updateAvailable: false,
10
+ checkError: null,
11
+ installing: false,
12
+ installProgress: 0,
13
+ installMessage: '',
14
+ installDone: false,
15
+ installError: null,
16
+ }),
17
+ actions: {
18
+ async check() {
19
+ this.checking = true
20
+ this.checkError = null
21
+ try {
22
+ const res = await api.update.check()
23
+ this.currentVersion = res.current
24
+ this.latestVersion = res.latest
25
+ this.updateAvailable = res.updateAvailable
26
+ } catch (e) {
27
+ this.checkError = e.message
28
+ } finally {
29
+ this.checking = false
30
+ }
31
+ },
32
+ async install() {
33
+ this.installing = true
34
+ this.installProgress = 0
35
+ this.installMessage = 'Starting...'
36
+ this.installDone = false
37
+ this.installError = null
38
+ try {
39
+ await api.update.install()
40
+ } catch (e) {
41
+ this.installError = e.message
42
+ this.installing = false
43
+ }
44
+ },
45
+ handleEvent(msg) {
46
+ switch (msg.type) {
47
+ case 'update:progress':
48
+ this.installProgress = msg.percent
49
+ this.installMessage = msg.message
50
+ break
51
+ case 'update:done':
52
+ this.installProgress = 100
53
+ this.installMessage = msg.message
54
+ this.installDone = true
55
+ this.installing = false
56
+ this.updateAvailable = false
57
+ this.latestVersion = msg.version
58
+ break
59
+ case 'update:error':
60
+ this.installError = msg.error
61
+ this.installing = false
62
+ break
63
+ }
64
+ },
65
+ reset() {
66
+ this.installing = false
67
+ this.installProgress = 0
68
+ this.installMessage = ''
69
+ this.installDone = false
70
+ this.installError = null
71
+ },
72
+ },
73
+ })
@@ -0,0 +1,42 @@
1
+ <template>
2
+ <div>
3
+ <div class="page-header">
4
+ <h2>About</h2>
5
+ </div>
6
+ <Card>
7
+ <template #content>
8
+ <div style="text-align:center;padding:40px">
9
+ <pre class="about-banner">{{ banner }}</pre>
10
+ <div style="font-size:12px;color:var(--ink-500);letter-spacing:3px;text-transform:uppercase;margin-bottom:20px">
11
+ Automatic Anime Downloader &mdash; v1.4.4
12
+ </div>
13
+ <div style="color:var(--text-dim);font-size:13px;max-width:400px;margin:0 auto;line-height:1.8">
14
+ Automatically downloads new anime episodes.<br />
15
+ Works with your AniList watching list or manual mode.
16
+ </div>
17
+ </div>
18
+ </template>
19
+ </Card>
20
+ </div>
21
+ </template>
22
+
23
+ <script setup>
24
+ const banner = `
25
+ █████╗ ███╗ ██╗██╗ █████╗ ██╗ ██╗████████╗ ██████╗
26
+ ██╔══██╗████╗ ██║██║ ██╔══██╗██║ ██║╚══██╔══╝██╔═══██╗
27
+ ███████║██╔██╗ ██║██║ ███████║██║ ██║ ██║ ██║ ██║
28
+ ██╔══██║██║╚██╗██║██║ ██╔══██║██║ ██║ ██║ ██║ ██║
29
+ ██║ ██║██║ ╚████║██║ ██║ ██║╚██████╔╝ ██║ ╚██████╔╝
30
+ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝
31
+ `
32
+ </script>
33
+
34
+ <style scoped>
35
+ .about-banner {
36
+ font-family: var(--font-logo);
37
+ font-size: 10px;
38
+ line-height: 1.2;
39
+ color: var(--red-primary);
40
+ margin-bottom: 16px;
41
+ }
42
+ </style>
@@ -0,0 +1,122 @@
1
+ <template>
2
+ <div>
3
+ <div class="page-header">
4
+ <h2>AniList Sync</h2>
5
+ <div class="subtitle">Browse your AniList and add to tracking</div>
6
+ </div>
7
+
8
+ <div class="anilist-toolbar">
9
+ <Select v-model="selectedStatus" @change="fetchList" :options="statusOptions" optionValue="value" style="width:180px">
10
+ <template #value="{ value }">
11
+ {{ statusOptions.find(o => o.value === value)?.label || value }}
12
+ </template>
13
+ <template #option="{ option }">
14
+ {{ option.label }}
15
+ </template>
16
+ </Select>
17
+ <Button label="Refresh" @click="fetchList" />
18
+ <div style="flex:1;min-width:10px"></div>
19
+ <InputText v-model="manualTitle" placeholder="Search AniList..." style="width:220px" />
20
+ <Button label="Search" @click="searchAnilist" />
21
+ </div>
22
+
23
+ <Skeleton v-if="loading" width="100%" height="300px" />
24
+
25
+ <div v-else-if="searchResults.length">
26
+ <h3 style="color:var(--text-bright);font-size:14px;margin-bottom:12px">Search Results</h3>
27
+ <div class="list-group">
28
+ <div v-for="r in searchResults" :key="r.anilistId" class="anime-card-row" @click="addFromSearch(r)">
29
+ <img v-if="r.posterUrl" :src="r.posterUrl" class="anime-poster" @error="handleImgError" />
30
+ <div class="anime-info">
31
+ <div class="anime-title">{{ r.titles?.english || r.titles?.romaji }}</div>
32
+ <div class="anime-meta">{{ r.year || '?' }} &mdash; {{ r.episodes || '?' }} eps &mdash; {{ r.status }}</div>
33
+ </div>
34
+ <Button label="Add" severity="primary" size="small" @click.stop="addFromSearch(r)" />
35
+ </div>
36
+ </div>
37
+ </div>
38
+
39
+ <div v-else-if="anilistEntries.length">
40
+ <h3 style="color:var(--text-bright);font-size:14px;margin-bottom:12px">{{ selectedStatus }} ({{ anilistEntries.length }})</h3>
41
+ <div class="list-group">
42
+ <div v-for="entry in anilistEntries" :key="entry.anilistId" class="anime-card-row" @click="addEntry(entry)">
43
+ <img v-if="entry.posterUrl" :src="entry.posterUrl" class="anime-poster" @error="handleImgError" />
44
+ <div class="anime-info">
45
+ <div class="anime-title">{{ entry.titles?.english || entry.titles?.romaji }}</div>
46
+ <div class="anime-meta">{{ entry.progress || 0 }}/{{ entry.totalEpisodes || '?' }} eps &mdash; {{ entry.animeStatus }}</div>
47
+ </div>
48
+ <Button label="Track" severity="primary" size="small" @click.stop="addEntry(entry)" />
49
+ </div>
50
+ </div>
51
+ </div>
52
+
53
+ <Card v-else>
54
+ <template #content>
55
+ <div class="empty-content">
56
+ <h3>No entries found</h3>
57
+ <p>Connect AniList in Settings or add manually.</p>
58
+ </div>
59
+ </template>
60
+ </Card>
61
+ </div>
62
+ </template>
63
+
64
+ <script setup>
65
+ import { ref, onMounted } from 'vue'
66
+ import { api } from '../api/client'
67
+
68
+ const loading = ref(false)
69
+ const selectedStatus = ref('CURRENT')
70
+ const manualTitle = ref('')
71
+ const anilistEntries = ref([])
72
+ const searchResults = ref([])
73
+
74
+ 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"
75
+
76
+ function handleImgError(e) {
77
+ e.target.src = FALLBACK_POSTER
78
+ }
79
+
80
+ const statusOptions = [
81
+ { label: 'CURRENT (Watching)', value: 'CURRENT' },
82
+ { label: 'PLANNING', value: 'PLANNING' },
83
+ { label: 'PAUSED', value: 'PAUSED' },
84
+ { label: 'REPEATING', value: 'REPEATING' },
85
+ ]
86
+
87
+ async function fetchList() {
88
+ loading.value = true
89
+ try {
90
+ anilistEntries.value = await api.anilist.list(selectedStatus.value)
91
+ searchResults.value = []
92
+ } catch (e) {
93
+ console.error(e)
94
+ } finally {
95
+ loading.value = false
96
+ }
97
+ }
98
+
99
+ async function searchAnilist() {
100
+ if (!manualTitle.value) return
101
+ loading.value = true
102
+ try {
103
+ searchResults.value = await api.anilist.search(manualTitle.value)
104
+ } catch (e) {
105
+ console.error(e)
106
+ } finally {
107
+ loading.value = false
108
+ }
109
+ }
110
+
111
+ async function addEntry(entry) {
112
+ const title = entry.titles?.english || entry.titles?.romaji
113
+ await api.anime.add({ title, anilistId: entry.anilistId, poster_url: entry.posterUrl })
114
+ }
115
+
116
+ async function addFromSearch(r) {
117
+ const title = r.titles?.english || r.titles?.romaji
118
+ await api.anime.add({ title, anilistId: r.anilistId, poster_url: r.posterUrl })
119
+ }
120
+
121
+ onMounted(fetchList)
122
+ </script>
@@ -0,0 +1,202 @@
1
+ <template>
2
+ <div>
3
+ <div class="page-header">
4
+ <h2>{{ anime?.anilist_title || anime?.title || 'Anime' }}</h2>
5
+ <div class="subtitle">
6
+ <router-link to="/anime">&larr; Back to Anime</router-link>
7
+ </div>
8
+ </div>
9
+
10
+ <Skeleton v-if="loading" width="100%" height="400px" />
11
+
12
+ <Card v-else-if="!anime">
13
+ <template #content>
14
+ <div class="empty-content"><h3>Anime not found</h3></div>
15
+ </template>
16
+ </Card>
17
+
18
+ <template v-else>
19
+ <Card style="margin-bottom:16px">
20
+ <template #content>
21
+ <div class="anime-detail-layout">
22
+ <img v-if="anime.poster_url" :src="anime.poster_url" class="anime-detail-poster" @error="handleImgError" />
23
+ <div style="flex:1">
24
+ <div style="margin-bottom:12px">
25
+ <Tag :value="anime.confirmed_site_title ? 'Matched' : 'Not Matched'" :severity="anime.confirmed_site_title ? 'success' : 'secondary'" />
26
+ <Tag :value="anime.enabled ? 'Enabled' : 'Disabled'" :severity="anime.enabled ? 'success' : 'danger'" style="margin-left:6px" />
27
+ <Tag :value="anime.source" severity="info" style="margin-left:6px" />
28
+ </div>
29
+ <div v-if="anime.confirmed_site_title" style="font-size:12px;color:var(--ink-500);margin-bottom:12px">
30
+ Matched to: {{ anime.confirmed_site_title }}
31
+ <a @click="$router.push(`/match/${anime.id}`)" style="margin-left:8px;cursor:pointer">change</a>
32
+ </div>
33
+ <div class="form-row" style="margin-bottom:12px;max-width:400px">
34
+ <div class="form-group" style="margin-bottom:0">
35
+ <label>Quality</label>
36
+ <Select v-model="overrideQuality" @change="setQuality" :options="qualityOptions" optionLabel="label" optionValue="value" fluid />
37
+ </div>
38
+ <div class="form-group" style="margin-bottom:0">
39
+ <label>Language</label>
40
+ <Select v-model="overrideLanguage" @change="setLanguage" :options="['sub', 'dub']" fluid />
41
+ </div>
42
+ </div>
43
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
44
+ <Button label="Download Unwatched" severity="primary" @click="downloadAnime" />
45
+ <Button v-if="!anime.confirmed_site_title" label="Find Match" @click="$router.push(`/match/${anime.id}`)" />
46
+ <Button :label="anime.enabled ? 'Disable' : 'Enable'" @click="toggleEnabled" />
47
+ <Button label="Remove" severity="danger" @click="removeAnime" />
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </template>
52
+ </Card>
53
+
54
+ <Tabs v-model:value="epFilter">
55
+ <TabList>
56
+ <Tab value="">All ({{ episodes.length }})</Tab>
57
+ <Tab value="done">Done ({{ stats.done }})</Tab>
58
+ <Tab value="failed">Failed ({{ stats.failed }})</Tab>
59
+ <Tab value="pending">Pending ({{ stats.pending }})</Tab>
60
+ </TabList>
61
+ <TabPanels>
62
+ <TabPanel :value="epFilter">
63
+ <DataTable v-if="filteredEpisodes.length" :value="filteredEpisodes" dataKey="id" stripedRows>
64
+ <Column field="episode_num" header="EP">
65
+ <template #body="{ data }">
66
+ <span style="font-family:var(--font-mono);font-size:12px">{{ data.episode_num }}</span>
67
+ </template>
68
+ </Column>
69
+ <Column field="status" header="Status">
70
+ <template #body="{ data }">
71
+ <Tag :value="data.status" :severity="statusSeverity(data.status)" size="small" />
72
+ </template>
73
+ </Column>
74
+ <Column field="filename" header="Filename">
75
+ <template #body="{ data }">
76
+ <span style="font-size:12px;color:var(--ink-700)">{{ data.filename || '-' }}</span>
77
+ </template>
78
+ </Column>
79
+ <Column field="downloaded_at" header="Downloaded">
80
+ <template #body="{ data }">
81
+ <span style="font-size:12px;color:var(--ink-700)">{{ data.downloaded_at ? data.downloaded_at.slice(0, 10) : '-' }}</span>
82
+ </template>
83
+ </Column>
84
+ <Column header="">
85
+ <template #body="{ data }">
86
+ <Button v-if="data.status === 'failed'" label="Retry" size="small" @click="retryEpisode(data)" />
87
+ </template>
88
+ </Column>
89
+ </DataTable>
90
+ <div v-else class="empty-content" style="padding:30px">
91
+ <h3>No {{ epFilter || '' }} episodes</h3>
92
+ </div>
93
+ </TabPanel>
94
+ </TabPanels>
95
+ </Tabs>
96
+ </template>
97
+ </div>
98
+ </template>
99
+
100
+ <script setup>
101
+ import { ref, computed, onMounted } from 'vue'
102
+ import { useRoute, useRouter } from 'vue-router'
103
+ import { api } from '../api/client'
104
+ import { useDownloadStore } from '../stores/download'
105
+ import DataTable from 'primevue/datatable'
106
+ import Column from 'primevue/column'
107
+ import Tabs from 'primevue/tabs'
108
+ import TabList from 'primevue/tablist'
109
+ import Tab from 'primevue/tab'
110
+ import TabPanels from 'primevue/tabpanels'
111
+ import TabPanel from 'primevue/tabpanel'
112
+
113
+ const props = defineProps({ id: String })
114
+ const route = useRoute()
115
+ const router = useRouter()
116
+ const downloadStore = useDownloadStore()
117
+
118
+ const loading = ref(true)
119
+ const anime = ref(null)
120
+ const episodes = ref([])
121
+ const overrideQuality = ref(null)
122
+ const overrideLanguage = ref('sub')
123
+ const epFilter = ref('')
124
+
125
+ 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"
126
+
127
+ function handleImgError(e) {
128
+ e.target.src = FALLBACK_POSTER
129
+ }
130
+
131
+ const qualityOptions = [
132
+ { label: 'Default', value: null },
133
+ { label: '1080p', value: '1080p' },
134
+ { label: '720p', value: '720p' },
135
+ { label: '360p', value: '360p' },
136
+ ]
137
+
138
+ const stats = computed(() => {
139
+ const all = episodes.value
140
+ return {
141
+ done: all.filter(e => e.status === 'done').length,
142
+ failed: all.filter(e => e.status === 'failed').length,
143
+ pending: all.filter(e => e.status === 'pending' || e.status === 'downloading').length,
144
+ }
145
+ })
146
+
147
+ const filteredEpisodes = computed(() => {
148
+ if (!epFilter.value) return episodes.value
149
+ return episodes.value.filter(e => e.status === epFilter.value)
150
+ })
151
+
152
+ function statusSeverity(status) {
153
+ switch (status) {
154
+ case 'done': return 'success'
155
+ case 'failed': return 'danger'
156
+ case 'downloading': return 'info'
157
+ default: return 'secondary'
158
+ }
159
+ }
160
+
161
+ async function loadAnime() {
162
+ const id = props.id || route.params.id
163
+ try {
164
+ anime.value = await api.anime.get(id)
165
+ episodes.value = await api.anime.episodes(id)
166
+ overrideQuality.value = anime.value.override_quality || anime.value.quality || null
167
+ overrideLanguage.value = anime.value.language || 'sub'
168
+ } catch (e) {
169
+ console.error(e)
170
+ } finally {
171
+ loading.value = false
172
+ }
173
+ }
174
+
175
+ function setQuality() {
176
+ api.anime.setQuality(anime.value.id, overrideQuality.value)
177
+ }
178
+
179
+ function setLanguage() {
180
+ api.anime.setLanguage(anime.value.id, overrideLanguage.value)
181
+ }
182
+
183
+ async function downloadAnime() {
184
+ await downloadStore.downloadAnime(anime.value.id, overrideQuality.value, overrideLanguage.value)
185
+ router.push('/download')
186
+ }
187
+
188
+ async function toggleEnabled() {
189
+ const newState = !anime.value.enabled
190
+ await api.anime.setEnabled(anime.value.id, newState)
191
+ anime.value.enabled = newState
192
+ }
193
+
194
+ async function removeAnime() {
195
+ if (confirm(`Remove ${anime.value.title}?`)) {
196
+ await api.anime.remove(anime.value.id)
197
+ router.push('/anime')
198
+ }
199
+ }
200
+
201
+ onMounted(loadAnime)
202
+ </script>
@@ -0,0 +1,110 @@
1
+ <template>
2
+ <div>
3
+ <div class="page-header">
4
+ <h2>Anime</h2>
5
+ <div class="subtitle">{{ filtered.length }} tracked</div>
6
+ </div>
7
+
8
+ <div class="anime-list-toolbar">
9
+ <InputText v-model="search" placeholder="Filter by title..." style="flex:1;max-width:320px" />
10
+ <Select v-model="statusFilter" :options="statusOptions" placeholder="Status" style="width:140px" />
11
+
12
+ <Tabs v-model:value="epFilter" style="flex:1">
13
+ <TabList>
14
+ <Tab value="">All</Tab>
15
+ <Tab value="done">Downloaded</Tab>
16
+ <Tab value="failed">Failed</Tab>
17
+ <Tab value="pending">Pending</Tab>
18
+ </TabList>
19
+ </Tabs>
20
+ </div>
21
+
22
+ <Skeleton v-if="loading" width="100%" height="300px" />
23
+
24
+ <Card v-else-if="filtered.length === 0">
25
+ <template #content>
26
+ <div class="empty-content"><h3>No anime found</h3></div>
27
+ </template>
28
+ </Card>
29
+
30
+ <div v-else class="anime-grid">
31
+ <div v-for="a in filtered" :key="a.id" class="anime-card" @click="$router.push(`/anime/${a.id}`)">
32
+ <img v-if="a.poster_url" :src="a.poster_url" class="anime-poster" @error="handleImgError" />
33
+ <div class="anime-info">
34
+ <div class="anime-title">{{ a.anilist_title || a.title }}</div>
35
+ <div class="anime-meta">
36
+ {{ a.source }}
37
+ <Tag :value="a.confirmed_site_title ? 'matched' : 'unmatched'" :severity="a.confirmed_site_title ? 'success' : 'secondary'" size="small" style="margin-left:6px" />
38
+ </div>
39
+ <div class="anime-progress">
40
+ <div class="bar" :style="{ width: progressPercent(a) + '%' }"></div>
41
+ </div>
42
+ <div class="anime-stats">
43
+ <span class="done">{{ a.stats?.done || 0 }} done</span>
44
+ <span class="failed">{{ a.stats?.failed || 0 }} failed</span>
45
+ <span class="pending">{{ a.stats?.pending || 0 }} pending</span>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </template>
52
+
53
+ <script setup>
54
+ import { ref, computed, onMounted } from 'vue'
55
+ import { api } from '../api/client'
56
+ import Tabs from 'primevue/tabs'
57
+ import TabList from 'primevue/tablist'
58
+ import Tab from 'primevue/tab'
59
+
60
+ const loading = ref(true)
61
+ const allAnime = ref([])
62
+ const search = ref('')
63
+ const statusFilter = ref('')
64
+ const epFilter = ref('')
65
+
66
+ const statusOptions = ['All', 'Matched', 'Unmatched']
67
+
68
+ 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"
69
+
70
+ function handleImgError(e) {
71
+ e.target.src = FALLBACK_POSTER
72
+ }
73
+
74
+ function progressPercent(a) {
75
+ const total = (a.stats?.done || 0) + (a.stats?.failed || 0) + (a.stats?.pending || 0)
76
+ if (!total) return 0
77
+ return Math.round(((a.stats?.done || 0) / total) * 100)
78
+ }
79
+
80
+ const filtered = computed(() => {
81
+ let list = allAnime.value
82
+ if (search.value) {
83
+ const s = search.value.toLowerCase()
84
+ list = list.filter((a) => (a.anilist_title || a.title).toLowerCase().includes(s))
85
+ }
86
+ if (statusFilter.value === 'Matched') {
87
+ list = list.filter((a) => a.confirmed_site_title)
88
+ } else if (statusFilter.value === 'Unmatched') {
89
+ list = list.filter((a) => !a.confirmed_site_title)
90
+ }
91
+ if (epFilter.value === 'done') {
92
+ list = list.filter((a) => (a.stats?.done || 0) > 0)
93
+ } else if (epFilter.value === 'failed') {
94
+ list = list.filter((a) => (a.stats?.failed || 0) > 0)
95
+ } else if (epFilter.value === 'pending') {
96
+ list = list.filter((a) => (a.stats?.pending || 0) > 0)
97
+ }
98
+ return list
99
+ })
100
+
101
+ onMounted(async () => {
102
+ try {
103
+ allAnime.value = await api.anime.list()
104
+ } catch (e) {
105
+ console.error(e)
106
+ } finally {
107
+ loading.value = false
108
+ }
109
+ })
110
+ </script>