@serifpersia/dango 3.3.9 → 3.4.1
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 +1 -1
- package/client/dist/assets/{AnimeCard-URCiiy5W.js → AnimeCard-B0oMgXBm.js} +1 -1
- package/client/dist/assets/{AnimeInfo-e3z3er9t.js → AnimeInfo-o0Udf0og.js} +1 -1
- package/client/dist/assets/{AnimeInfoPage-B7Djlmy_.js → AnimeInfoPage-CojMIEuf.js} +2 -2
- package/client/dist/assets/Asmr-2V3yCCiK.js +2 -0
- package/client/dist/assets/AsmrResetProgressModal-C9R7HlNa.js +1 -0
- package/client/dist/assets/AvSyncCalibrator-Bo3820j3.js +10 -0
- package/client/dist/assets/Home-C8uXTzIM.js +1 -0
- package/client/dist/assets/Insights-CRqBfZuK.js +1 -0
- package/client/dist/assets/{LibraryListPage-B0qWqhb_.js → LibraryListPage-B_r51cRi.js} +1 -1
- package/client/dist/assets/{ListeningList-CSqD8j1C.js → ListeningList-BWnwvJTt.js} +1 -1
- package/client/dist/assets/{Manga-Cg2YuObF.js → Manga-CI5cZg3h.js} +1 -1
- package/client/dist/assets/{MangaInfoPage-DcArhEsy.js → MangaInfoPage-BtkOrO34.js} +1 -1
- package/client/dist/assets/MangaReadPage-Uv72AYdY.js +1 -0
- package/client/dist/assets/{MangaResetProgressModal-D_yT3d1v.js → MangaResetProgressModal-CPMEqZyi.js} +1 -1
- package/client/dist/assets/Mature-DXHWvoY5.js +1 -0
- package/client/dist/assets/MediaCard-DzA336CA.js +1 -0
- package/client/dist/assets/Music-C4dM0BQx.js +1 -0
- package/client/dist/assets/OptionListMenu-CpwxAC3J.js +1 -0
- package/client/dist/assets/Player-CpMvWTkH.js +56 -0
- package/client/dist/assets/PlayerSettings-pMU85k7p.js +1 -0
- package/client/dist/assets/QueueOptionsButton-1M7MdKzM.js +1 -0
- package/client/dist/assets/{QueueRail-B7f1-e6i.js → QueueRail-Bpla2xD8.js} +1 -1
- package/client/dist/assets/Radio-DlmTC-qG.js +1 -0
- package/client/dist/assets/{ReadingList-BaIem8H1.js → ReadingList-wJMDUybm.js} +1 -1
- package/client/dist/assets/{ResetProgressModal-DES5Y-Fl.js → ResetProgressModal-CByRM1Fh.js} +1 -1
- package/client/dist/assets/Search-B94D0tRM.js +1 -0
- package/client/dist/assets/Settings-CcU3oNY8.js +21 -0
- package/client/dist/assets/{SkeletonGrid-BxfgjP4i.js → SkeletonGrid-BJyZkg_K.js} +1 -1
- package/client/dist/assets/Trackers-vz-gqxtm.js +2 -0
- package/client/dist/assets/Tv-Rji_lvQo.js +4 -0
- package/client/dist/assets/Tv-ZEA45gXK.css +1 -0
- package/client/dist/assets/TvInfo-Ciujd6os.js +1 -0
- package/client/dist/assets/TvPopup-CqlVRF-Y.js +1 -0
- package/client/dist/assets/{TvSearch-BWDwWnHe.js → TvSearch-BcKXQue9.js} +1 -1
- package/client/dist/assets/{TvWatchlist-D3n5_4MI.js → TvWatchlist-sjsiup2h.js} +1 -1
- package/client/dist/assets/Watchlist-DAMdvt7Z.js +1 -0
- package/client/dist/assets/{hls-BO_L1WoN.js → hls-Do_n10_Y.js} +1 -1
- package/client/dist/assets/index-B5SLDCAi.js +178 -0
- package/client/dist/assets/useAnimeInfoData-DU9X90no.js +1 -0
- package/client/dist/assets/useAsmr-D6cSjbvt.js +1 -0
- package/client/dist/assets/{useAsmrPopup-D_o9-QlR.js → useAsmrPopup-DK1Vx-HV.js} +1 -1
- package/client/dist/assets/useMangaLibrary-DcxPn0RM.js +1 -0
- package/client/dist/assets/{useMangaPopup-TFzkC-tG.js → useMangaPopup-BLwvRZn0.js} +1 -1
- package/client/dist/assets/useProviders-D3L1Chqc.js +1 -0
- package/client/dist/assets/useTv-B546u-Rx.js +1 -0
- package/client/dist/assets/useTvLibrary-C2rPeCVT.js +1 -0
- package/client/dist/assets/{vendor-query-BXRECSI6.js → vendor-query-BUTye89G.js} +1 -1
- package/client/dist/index.html +2 -2
- package/orchestrator.js +1 -2
- package/package.json +6 -6
- package/server/dist/app-auth.js +9 -29
- package/server/dist/app-hono.js +621 -0
- package/server/dist/config.js +0 -1
- package/server/dist/db/drizzle.js +32 -0
- package/server/dist/db/schema-anime.js +64 -0
- package/server/dist/db/schema-asmr.js +25 -0
- package/server/dist/db/schema-manga.js +31 -0
- package/server/dist/db/schema-tv.js +33 -0
- package/server/dist/db.js +75 -4
- package/server/dist/discord-gateway.js +1 -1
- package/server/dist/google.js +0 -7
- package/server/dist/hono/asmr.js +1 -0
- package/server/dist/hono/auth.js +56 -0
- package/server/dist/hono/music.js +419 -0
- package/server/dist/{controllers/proxy.controller.js → hono/proxy.js} +390 -394
- package/server/dist/hono/tv.js +197 -0
- package/server/dist/hono/watchlist.js +733 -0
- package/server/dist/lib/anilist.js +3 -3
- package/server/dist/lib/insights.js +185 -0
- package/server/dist/lib/ipc.js +1 -2
- package/server/dist/lib/kitsu.js +1 -28
- package/server/dist/lib/mal-import.js +291 -0
- package/server/dist/lib/migration.js +20 -34
- package/server/dist/lib/offline-db.js +36 -36
- package/server/dist/lib/tracker/manga-sync.service.js +21 -21
- package/server/dist/lib/tracker/sync.service.js +20 -20
- package/server/dist/lib/translate.js +79 -0
- package/server/dist/lib/ytmusic.js +0 -2
- package/server/dist/providers/title-matching.js +0 -1
- package/server/dist/repositories/asmr.repository.js +90 -87
- package/server/dist/repositories/insights.repository.js +24 -13
- package/server/dist/repositories/library.repository.js +33 -93
- package/server/dist/repositories/manga.repository.js +108 -116
- package/server/dist/repositories/notifications.repository.js +50 -31
- package/server/dist/repositories/queue.repository.js +36 -25
- package/server/dist/repositories/settings.repository.js +18 -8
- package/server/dist/repositories/shows-meta.repository.js +54 -41
- package/server/dist/repositories/temp-show-ids.repository.js +43 -31
- package/server/dist/repositories/tv.repository.js +98 -115
- package/server/dist/repositories/watched-episodes.repository.js +52 -20
- package/server/dist/repositories/watchlist.repository.js +36 -48
- package/server/dist/server.js +59 -151
- package/server/dist/sync.js +41 -4
- package/server/dist/trpc/context.js +7 -0
- package/server/dist/trpc/errors.js +21 -0
- package/server/dist/trpc/index.js +19 -0
- package/server/dist/trpc/router.js +49 -0
- package/server/dist/trpc/routers/asmr-library.js +122 -0
- package/server/dist/trpc/routers/asmr-progress.js +133 -0
- package/server/dist/trpc/routers/asmr.js +87 -0
- package/server/dist/trpc/routers/auth.js +247 -0
- package/server/dist/trpc/routers/continue-watching.js +83 -0
- package/server/dist/trpc/routers/data.js +1039 -0
- package/server/dist/trpc/routers/discord.js +288 -0
- package/server/dist/trpc/routers/discovery.js +15 -0
- package/server/dist/trpc/routers/insights.js +45 -0
- package/server/dist/trpc/routers/manga-library.js +280 -0
- package/server/dist/trpc/routers/manga-progress.js +181 -0
- package/server/dist/trpc/routers/manga.js +201 -0
- package/server/dist/{controllers/music.controller.js → trpc/routers/music.js} +155 -288
- package/server/dist/trpc/routers/notifications.js +80 -0
- package/server/dist/trpc/routers/progress.js +15 -0
- package/server/dist/trpc/routers/queue.js +95 -0
- package/server/dist/{routes/radio.routes.js → trpc/routers/radio.js} +30 -23
- package/server/dist/trpc/routers/settings.js +105 -0
- package/server/dist/trpc/routers/tracker.js +219 -0
- package/server/dist/trpc/routers/tv-library.js +145 -0
- package/server/dist/trpc/routers/tv-progress.js +171 -0
- package/server/dist/trpc/routers/tv.js +565 -0
- package/server/dist/trpc/routers/watchlist.js +564 -0
- package/server/dist/trpc/validation.js +159 -0
- package/server/dist/utils/security.utils.js +0 -18
- package/server/package.json +5 -10
- package/client/dist/assets/Asmr-C28TfSvX.js +0 -2
- package/client/dist/assets/AsmrResetProgressModal-6Xh71AgR.js +0 -1
- package/client/dist/assets/AvSyncCalibrator-BwCtRW0P.js +0 -10
- package/client/dist/assets/Home-BJdbHYja.js +0 -1
- package/client/dist/assets/Insights-CjRy42YE.js +0 -1
- package/client/dist/assets/MangaReadPage-YCETZRte.js +0 -1
- package/client/dist/assets/Mature-BGxRWkV5.js +0 -1
- package/client/dist/assets/MediaCard-CAzoxsRS.js +0 -1
- package/client/dist/assets/Music-Cgo-yQ7E.js +0 -1
- package/client/dist/assets/OptionListMenu--Owe9Jle.js +0 -1
- package/client/dist/assets/Player-yRXG2BrP.js +0 -56
- package/client/dist/assets/PlayerSettings-DrZLa0CC.js +0 -1
- package/client/dist/assets/QueueOptionsButton-BVZyPyKf.js +0 -1
- package/client/dist/assets/Radio-BFjxlzuF.js +0 -1
- package/client/dist/assets/Search-BOBo0Ogh.js +0 -1
- package/client/dist/assets/Settings-DOvNQ0gX.js +0 -21
- package/client/dist/assets/Trackers-DZ9KqOWv.js +0 -2
- package/client/dist/assets/Tv-5mATEU4p.js +0 -4
- package/client/dist/assets/Tv-C2SWcVzN.css +0 -1
- package/client/dist/assets/TvInfo-CF-5C5Fs.js +0 -1
- package/client/dist/assets/TvPopup-Dfqifj3s.js +0 -1
- package/client/dist/assets/Watchlist-B8MHNsAU.js +0 -1
- package/client/dist/assets/index-D2RkUJIN.js +0 -178
- package/client/dist/assets/useAnimeInfoData-BWShDHqE.js +0 -1
- package/client/dist/assets/useAsmr-FyfD9G7b.js +0 -1
- package/client/dist/assets/useMangaLibrary-BJU5x8Vz.js +0 -1
- package/client/dist/assets/useProviders-DDRxa8vl.js +0 -1
- package/client/dist/assets/useTv-Bh6WmNnZ.js +0 -1
- package/client/dist/assets/useTvLibrary-_Pqm8re1.js +0 -1
- package/server/dist/controllers/asmr-library.controller.js +0 -288
- package/server/dist/controllers/auth.controller.js +0 -225
- package/server/dist/controllers/data.controller.js +0 -767
- package/server/dist/controllers/insights.controller.js +0 -203
- package/server/dist/controllers/manga-library.controller.js +0 -350
- package/server/dist/controllers/settings.controller.js +0 -609
- package/server/dist/controllers/tv-library.controller.js +0 -314
- package/server/dist/controllers/watchlist.controller.js +0 -1069
- package/server/dist/request-logger.js +0 -16
- package/server/dist/routes/asmr-library.routes.js +0 -23
- package/server/dist/routes/asmr.routes.js +0 -81
- package/server/dist/routes/auth.routes.js +0 -25
- package/server/dist/routes/data.routes.js +0 -47
- package/server/dist/routes/discord-gateway.routes.js +0 -51
- package/server/dist/routes/insights.routes.js +0 -35
- package/server/dist/routes/lan-auth.routes.js +0 -46
- package/server/dist/routes/manga-library.routes.js +0 -25
- package/server/dist/routes/manga.routes.js +0 -141
- package/server/dist/routes/music.routes.js +0 -20
- package/server/dist/routes/providers.routes.js +0 -17
- package/server/dist/routes/proxy.routes.js +0 -12
- package/server/dist/routes/settings.routes.js +0 -24
- package/server/dist/routes/tracker.routes.js +0 -287
- package/server/dist/routes/translate.routes.js +0 -88
- package/server/dist/routes/tv-library.routes.js +0 -23
- package/server/dist/routes/tv.routes.js +0 -629
- package/server/dist/routes/watchlist.routes.js +0 -341
|
@@ -764,7 +764,7 @@ export async function findPosterByTitle(title, expected) {
|
|
|
764
764
|
}
|
|
765
765
|
}
|
|
766
766
|
catch {
|
|
767
|
-
// ignore
|
|
767
|
+
// ignore
|
|
768
768
|
}
|
|
769
769
|
try {
|
|
770
770
|
const kitsu = await kitsuSearchAnime({ query: clean, page: 1, perPage: 5 });
|
|
@@ -1030,7 +1030,7 @@ export async function batchGetShowStatuses(ids) {
|
|
|
1030
1030
|
}
|
|
1031
1031
|
}
|
|
1032
1032
|
catch {
|
|
1033
|
-
//
|
|
1033
|
+
// ignore
|
|
1034
1034
|
}
|
|
1035
1035
|
}
|
|
1036
1036
|
return result;
|
|
@@ -1324,7 +1324,7 @@ export async function getSchedule(date, format, adult = false) {
|
|
|
1324
1324
|
shows.push(withScheduleFields(meta, entry.episode, entry.airingAt));
|
|
1325
1325
|
}
|
|
1326
1326
|
catch {
|
|
1327
|
-
//
|
|
1327
|
+
// ignore
|
|
1328
1328
|
}
|
|
1329
1329
|
}
|
|
1330
1330
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import logger from '../logger.js';
|
|
2
|
+
import { InsightsRepository } from '../repositories/insights.repository.js';
|
|
3
|
+
export async function getWatchInsights(db) {
|
|
4
|
+
const [core, activityGrid, hourlyDist, seasonality, allWatches, watchedShows, droppedWarning, velocities,] = (await Promise.all([
|
|
5
|
+
InsightsRepository.getCoreStats(db),
|
|
6
|
+
InsightsRepository.getActivityGrid(db),
|
|
7
|
+
InsightsRepository.getHourlyDist(db),
|
|
8
|
+
InsightsRepository.getSeasonality(db),
|
|
9
|
+
InsightsRepository.getAllWatches(db),
|
|
10
|
+
InsightsRepository.getWatchedShowsMeta(db),
|
|
11
|
+
InsightsRepository.getDroppedShows(db),
|
|
12
|
+
InsightsRepository.getCompletionVelocities(db),
|
|
13
|
+
]));
|
|
14
|
+
const bingeFactor = activityGrid.length > 0 ? Math.max(...activityGrid.map((a) => a.count)) : 0;
|
|
15
|
+
const sessions = [];
|
|
16
|
+
if (allWatches.length > 0) {
|
|
17
|
+
let currentSessionSeconds = allWatches[0].effectiveSeconds ?? allWatches[0].currentTime;
|
|
18
|
+
for (let i = 1; i < allWatches.length; i++) {
|
|
19
|
+
const prev = new Date(allWatches[i - 1].watchedAt).getTime();
|
|
20
|
+
const curr = new Date(allWatches[i].watchedAt).getTime();
|
|
21
|
+
const gap = curr - prev;
|
|
22
|
+
if (Number.isFinite(gap) && gap > 0 && gap < 3600000) {
|
|
23
|
+
currentSessionSeconds += allWatches[i].effectiveSeconds ?? allWatches[i].currentTime;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
sessions.push(currentSessionSeconds);
|
|
27
|
+
currentSessionSeconds = allWatches[i].effectiveSeconds ?? allWatches[i].currentTime;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
sessions.push(currentSessionSeconds);
|
|
31
|
+
}
|
|
32
|
+
const avgSessionMinutes = sessions.length > 0 ? Math.round(sessions.reduce((a, b) => a + b, 0) / sessions.length / 60) : 0;
|
|
33
|
+
const genreCounts = {};
|
|
34
|
+
let totalPopScore = 0;
|
|
35
|
+
let popCount = 0;
|
|
36
|
+
for (const show of watchedShows) {
|
|
37
|
+
let genres = [];
|
|
38
|
+
if (show.genres) {
|
|
39
|
+
try {
|
|
40
|
+
if (show.genres.startsWith('[')) {
|
|
41
|
+
genres = JSON.parse(show.genres);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
genres = show.genres.split(',').map((g) => g.trim());
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
logger.warn({ err: e, showId: show.id }, 'Failed to parse genres for insights');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const g of genres) {
|
|
52
|
+
genreCounts[g] = (genreCounts[g] || 0) + 1;
|
|
53
|
+
}
|
|
54
|
+
if (show.popularityScore) {
|
|
55
|
+
let score = show.popularityScore;
|
|
56
|
+
if (score > 10)
|
|
57
|
+
score = score / 10;
|
|
58
|
+
totalPopScore += score;
|
|
59
|
+
popCount++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const topGenre = Object.entries(genreCounts).sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
63
|
+
const personaMap = {
|
|
64
|
+
Action: 'Shonen Warrior',
|
|
65
|
+
Romance: 'Hopeless Romantic',
|
|
66
|
+
Comedy: 'Chaos Enjoyer',
|
|
67
|
+
'Slice of Life': 'Vibe Seeker',
|
|
68
|
+
Horror: 'Fearless Watcher',
|
|
69
|
+
Fantasy: 'Isekai Traveller',
|
|
70
|
+
'Sci-Fi': 'Future Scientist',
|
|
71
|
+
Drama: 'Feels Collector',
|
|
72
|
+
};
|
|
73
|
+
const persona = personaMap[topGenre || ''] || 'Anime Enthusiast';
|
|
74
|
+
const avgCompletionDays = velocities.length > 0
|
|
75
|
+
? Math.round(velocities.reduce((a, b) => a + b.daysToFinish, 0) / velocities.length)
|
|
76
|
+
: 0;
|
|
77
|
+
return {
|
|
78
|
+
totalHours: Math.round((core?.totalSeconds || 0) / 3600),
|
|
79
|
+
totalEpisodes: core?.totalEpisodes || 0,
|
|
80
|
+
totalAnime: core?.totalAnime || 0,
|
|
81
|
+
completedAnime: core?.completedCount || 0,
|
|
82
|
+
completionRate: core?.totalWatchlist > 0 ? Math.round((core.completedCount / core.totalWatchlist) * 100) : 0,
|
|
83
|
+
persona,
|
|
84
|
+
bingeFactor,
|
|
85
|
+
avgSessionMinutes,
|
|
86
|
+
avgCompletionDays,
|
|
87
|
+
popularityScore: popCount > 0 ? Math.round(totalPopScore / popCount) : 0,
|
|
88
|
+
genreSplit: Object.entries(genreCounts)
|
|
89
|
+
.map(([name, count]) => ({ name, count }))
|
|
90
|
+
.sort((a, b) => b.count - a.count)
|
|
91
|
+
.slice(0, 8) || [],
|
|
92
|
+
activityGrid: activityGrid || [],
|
|
93
|
+
hourlyDist: Array.from({ length: 24 }, (_, i) => {
|
|
94
|
+
const hour = i.toString().padStart(2, '0');
|
|
95
|
+
return { hour, count: hourlyDist?.find((d) => d.hour === hour)?.count || 0 };
|
|
96
|
+
}) || [],
|
|
97
|
+
seasonality: Array.from({ length: 12 }, (_, i) => {
|
|
98
|
+
const month = (i + 1).toString().padStart(2, '0');
|
|
99
|
+
return {
|
|
100
|
+
month,
|
|
101
|
+
seconds: seasonality?.find((s) => s.month === month)?.seconds || 0,
|
|
102
|
+
};
|
|
103
|
+
}) || [],
|
|
104
|
+
droppedShows: (droppedWarning || []).slice(0, 5),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export async function getGenreCards(db) {
|
|
108
|
+
const rows = await InsightsRepository.getWatchedEpisodesWithMeta(db);
|
|
109
|
+
const genreData = {};
|
|
110
|
+
for (const row of rows) {
|
|
111
|
+
let genres = [];
|
|
112
|
+
if (row.genres) {
|
|
113
|
+
try {
|
|
114
|
+
if (row.genres.startsWith('[')) {
|
|
115
|
+
genres = JSON.parse(row.genres);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
genres = row.genres.split(',').map((g) => g.trim());
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
logger.warn({ err: e, showId: row.showId }, 'Failed to parse genres for genre cards');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const timeWatched = (row.effectiveSeconds ?? row.currentTime) || 0;
|
|
126
|
+
let score = row.popularityScore || 0;
|
|
127
|
+
if (score > 10)
|
|
128
|
+
score = score / 10;
|
|
129
|
+
for (const genre of genres) {
|
|
130
|
+
if (!genreData[genre]) {
|
|
131
|
+
genreData[genre] = { count: 0, totalTime: 0, scores: [], showWatches: {}, showMeta: {} };
|
|
132
|
+
}
|
|
133
|
+
genreData[genre].count++;
|
|
134
|
+
genreData[genre].totalTime += timeWatched;
|
|
135
|
+
if (score > 0)
|
|
136
|
+
genreData[genre].scores.push(score);
|
|
137
|
+
genreData[genre].showWatches[row.showId] = (genreData[genre].showWatches[row.showId] || 0) + 1;
|
|
138
|
+
if (row.name || row.thumbnail) {
|
|
139
|
+
genreData[genre].showMeta[row.showId] = {
|
|
140
|
+
name: row.name,
|
|
141
|
+
nativeName: row.nativeName,
|
|
142
|
+
englishName: row.englishName,
|
|
143
|
+
thumbnail: row.thumbnail,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const genreCards = Object.entries(genreData).map(([name, data]) => {
|
|
149
|
+
const topShows = Object.entries(data.showWatches)
|
|
150
|
+
.sort((a, b) => b[1] - a[1])
|
|
151
|
+
.slice(0, 4)
|
|
152
|
+
.map(([showId]) => ({
|
|
153
|
+
id: showId,
|
|
154
|
+
name: data.showMeta[showId]?.name || '',
|
|
155
|
+
nativeName: data.showMeta[showId]?.nativeName,
|
|
156
|
+
englishName: data.showMeta[showId]?.englishName,
|
|
157
|
+
thumbnail: data.showMeta[showId]?.thumbnail || '',
|
|
158
|
+
}));
|
|
159
|
+
return {
|
|
160
|
+
rank: 0,
|
|
161
|
+
name,
|
|
162
|
+
count: data.count,
|
|
163
|
+
meanScore: data.scores.length > 0 ? data.scores.reduce((a, b) => a + b, 0) / data.scores.length : 0,
|
|
164
|
+
timeWatched: formatTime(data.totalTime),
|
|
165
|
+
topShows,
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
genreCards.sort((a, b) => b.count - a.count);
|
|
169
|
+
genreCards.forEach((card, i) => {
|
|
170
|
+
card.rank = i + 1;
|
|
171
|
+
});
|
|
172
|
+
return genreCards;
|
|
173
|
+
}
|
|
174
|
+
function formatTime(seconds) {
|
|
175
|
+
const days = Math.floor(seconds / 86400);
|
|
176
|
+
const hours = Math.floor((seconds % 86400) / 3600);
|
|
177
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
178
|
+
if (days > 0) {
|
|
179
|
+
return `${days} days ${hours > 0 ? `${hours} hours` : ''}`;
|
|
180
|
+
}
|
|
181
|
+
if (hours > 0) {
|
|
182
|
+
return `${hours} hours${minutes > 0 ? ` ${minutes} mins` : ''}`;
|
|
183
|
+
}
|
|
184
|
+
return `${minutes} mins`;
|
|
185
|
+
}
|
package/server/dist/lib/ipc.js
CHANGED
package/server/dist/lib/kitsu.js
CHANGED
|
@@ -44,7 +44,6 @@ function asArray(data) {
|
|
|
44
44
|
return [];
|
|
45
45
|
return Array.isArray(data) ? data : [data];
|
|
46
46
|
}
|
|
47
|
-
/** Builds a map of kitsu relationship id -> included resource for a given type. */
|
|
48
47
|
function includedMap(entry, included, type) {
|
|
49
48
|
if (!included || !entry.relationships)
|
|
50
49
|
return [];
|
|
@@ -57,7 +56,6 @@ function includedMap(entry, included, type) {
|
|
|
57
56
|
}
|
|
58
57
|
return included.filter((i) => i.type === type && ids.has(i.id));
|
|
59
58
|
}
|
|
60
|
-
/** Kitsu status/subtype/season values are lowercase; map to AniList-style values. */
|
|
61
59
|
const STATUS_MAP = {
|
|
62
60
|
current: 'RELEASING',
|
|
63
61
|
finished: 'FINISHED',
|
|
@@ -125,7 +123,6 @@ function anilistSortToKitsu(sort) {
|
|
|
125
123
|
return '-userCount';
|
|
126
124
|
}
|
|
127
125
|
}
|
|
128
|
-
/** Translates an AniList-style status filter to a Kitsu filter value. */
|
|
129
126
|
function statusToKitsuFilter(status) {
|
|
130
127
|
if (!status)
|
|
131
128
|
return undefined;
|
|
@@ -143,7 +140,6 @@ function seasonToKitsu(season) {
|
|
|
143
140
|
return undefined;
|
|
144
141
|
return season.toLowerCase();
|
|
145
142
|
}
|
|
146
|
-
/** Kitsu genre-ish taxonomy: `categories`. */
|
|
147
143
|
const CATEGORY_ALIASES = {
|
|
148
144
|
'Sci-Fi': 'Science Fiction',
|
|
149
145
|
'Sci Fi': 'Science Fiction',
|
|
@@ -154,9 +150,6 @@ function normalizeCategory(cat) {
|
|
|
154
150
|
const alias = Object.entries(CATEGORY_ALIASES).find(([k]) => k.toLowerCase() === c.toLowerCase());
|
|
155
151
|
return alias ? alias[1] : c;
|
|
156
152
|
}
|
|
157
|
-
/**
|
|
158
|
-
* Resolves the included anime resource linked by a mapping entry's `item` relationship.
|
|
159
|
-
*/
|
|
160
153
|
function mappingItem(entry, included) {
|
|
161
154
|
const rel = entry.relationships?.item?.data;
|
|
162
155
|
if (!rel)
|
|
@@ -165,10 +158,6 @@ function mappingItem(entry, included) {
|
|
|
165
158
|
return undefined;
|
|
166
159
|
return included.find((i) => i.type === rel.type && i.id === rel.id);
|
|
167
160
|
}
|
|
168
|
-
/**
|
|
169
|
-
* Fetches a list of anime from Kitsu and normalizes entries to AnilistMedia.
|
|
170
|
-
* `include=mappings,categories` gives AniList/MAL ids + genres in one request.
|
|
171
|
-
*/
|
|
172
161
|
export async function kitsuSearchAnime(options = {}) {
|
|
173
162
|
const { query, page = 1, perPage = 14, format, status, season, seasonYear, genre, genre_in, genre_not_in, averageScore_greater, episodes_greater, isAdult, sort, } = options;
|
|
174
163
|
const filters = [];
|
|
@@ -223,7 +212,6 @@ export async function kitsuSearchAnime(options = {}) {
|
|
|
223
212
|
}
|
|
224
213
|
return results;
|
|
225
214
|
}
|
|
226
|
-
/** Deduplicates by the resolved AniList/MAL id (keeps the first occurrence). */
|
|
227
215
|
export function dedupeKitsuMedia(list) {
|
|
228
216
|
const seen = new Set();
|
|
229
217
|
const out = [];
|
|
@@ -236,7 +224,6 @@ export function dedupeKitsuMedia(list) {
|
|
|
236
224
|
}
|
|
237
225
|
return out;
|
|
238
226
|
}
|
|
239
|
-
/** Converts a Kitsu entry + included resources into AnilistMedia (id = AniList or MAL id). */
|
|
240
227
|
function normalizeKitsuEntry(entry, included, knownIds) {
|
|
241
228
|
const a = entry.attributes;
|
|
242
229
|
const mappings = includedMap(entry, included, 'mappings');
|
|
@@ -298,9 +285,6 @@ function normalizeKitsuEntry(entry, included, knownIds) {
|
|
|
298
285
|
: null,
|
|
299
286
|
};
|
|
300
287
|
}
|
|
301
|
-
/**
|
|
302
|
-
* Finds the Kitsu entry id for a given AniList id via the mappings endpoint.
|
|
303
|
-
*/
|
|
304
288
|
async function kitsuIdByAnilistId(anilistId) {
|
|
305
289
|
const json = await kitsuFetch(`/mappings?filter[externalSite]=anilist%2Fanime&filter[externalId]=${anilistId}&include=item`);
|
|
306
290
|
const entries = asArray(json?.data);
|
|
@@ -340,12 +324,11 @@ export async function kitsuBatchGetStatuses(anilistIds) {
|
|
|
340
324
|
result.set(id, status);
|
|
341
325
|
}
|
|
342
326
|
catch {
|
|
343
|
-
//
|
|
327
|
+
// ignore
|
|
344
328
|
}
|
|
345
329
|
}
|
|
346
330
|
return result;
|
|
347
331
|
}
|
|
348
|
-
/** Fetches full detail (genres + studios) for a Kitsu anime id. */
|
|
349
332
|
async function kitsuDetail(kitsuId, knownIds) {
|
|
350
333
|
const [animeJson, studiosJson] = await Promise.all([
|
|
351
334
|
kitsuFetch(`/anime/${kitsuId}?include=categories,mappings`),
|
|
@@ -384,7 +367,6 @@ export async function kitsuMetaByMalId(malId) {
|
|
|
384
367
|
return null;
|
|
385
368
|
return kitsuDetail(item.id, { malId });
|
|
386
369
|
}
|
|
387
|
-
/** Batch-resolves English/Japanese titles for MAL ids via the mappings endpoint. */
|
|
388
370
|
export async function kitsuTitlesByMalIds(malIds) {
|
|
389
371
|
const result = new Map();
|
|
390
372
|
const unique = [...new Set(malIds.filter((n) => Number.isFinite(n) && n > 0))];
|
|
@@ -424,7 +406,6 @@ export async function kitsuTitlesByMalIds(malIds) {
|
|
|
424
406
|
}));
|
|
425
407
|
return result;
|
|
426
408
|
}
|
|
427
|
-
/** Episode numbers for a show, via the episodes relationship. */
|
|
428
409
|
export async function kitsuEpisodes(anilistId, idMal) {
|
|
429
410
|
let kitsuId = null;
|
|
430
411
|
if (anilistId > 0)
|
|
@@ -462,7 +443,6 @@ export async function kitsuEpisodes(anilistId, idMal) {
|
|
|
462
443
|
const max = Math.max(...all);
|
|
463
444
|
return Array.from({ length: max }, (_, i) => (i + 1).toString());
|
|
464
445
|
}
|
|
465
|
-
/** Trending proxy: currently-airing shows sorted by popularity (user count). */
|
|
466
446
|
export async function kitsuTrending(page = 1, perPage = 20, status) {
|
|
467
447
|
const statusFilter = statusToKitsuFilter(status);
|
|
468
448
|
const filters = statusFilter ? `filter[status]=${statusFilter}` : '';
|
|
@@ -474,7 +454,6 @@ export async function kitsuTrending(page = 1, perPage = 20, status) {
|
|
|
474
454
|
.map((e) => normalizeKitsuEntry(e, json.included ?? []))
|
|
475
455
|
.filter((m) => m !== null));
|
|
476
456
|
}
|
|
477
|
-
/** Spotlight: top popular shows with native Kitsu banners (3360x800). */
|
|
478
457
|
export async function kitsuSpotlight(page = 1, perPage = 20) {
|
|
479
458
|
const offset = (page - 1) * perPage;
|
|
480
459
|
const json = await kitsuFetch(`/anime?sort=-userCount&page[limit]=${perPage}&page[offset]=${offset}&include=mappings,categories`);
|
|
@@ -485,11 +464,6 @@ export async function kitsuSpotlight(page = 1, perPage = 20) {
|
|
|
485
464
|
.filter((m) => m !== null)
|
|
486
465
|
.filter((m) => !!m.bannerImage));
|
|
487
466
|
}
|
|
488
|
-
/**
|
|
489
|
-
* Latest releases: currently-airing shows. Kitsu has no reliable episode airdates,
|
|
490
|
-
* so we sort current shows by start date (newest first) and bump shows with a known
|
|
491
|
-
* next episode date to the front (soonest first).
|
|
492
|
-
*/
|
|
493
467
|
export async function kitsuLatestReleases(format = 'TV', page = 1, size = 12) {
|
|
494
468
|
const needed = page * size;
|
|
495
469
|
const formatFilter = format && format !== 'ALL' ? `filter[subtype]=${format.toLowerCase()}` : '';
|
|
@@ -528,7 +502,6 @@ export async function kitsuLatestReleases(format = 'TV', page = 1, size = 12) {
|
|
|
528
502
|
function fallbackStartRank(m) {
|
|
529
503
|
return ((m.startDate?.year ?? 0) * 10000 + (m.startDate?.month ?? 0) * 100 + (m.startDate?.day ?? 0));
|
|
530
504
|
}
|
|
531
|
-
/** Seasonal list for a given season/year, matching AniList's POPULARITY_DESC order. */
|
|
532
505
|
export async function kitsuSeasonal(season, year, format, page = 1, size = 14) {
|
|
533
506
|
const filters = [`filter[season]=${seasonToKitsu(season)}`, `filter[seasonYear]=${year}`];
|
|
534
507
|
if (format && format !== 'ALL' && format !== 'ADULT') {
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
2
|
+
import { searchAnilistByTitle, isAnilistRateLimited, getShowMetaById } from './anilist.js';
|
|
3
|
+
import { kitsuSearchAnime } from './kitsu.js';
|
|
4
|
+
import { offlineDb } from './offline-db.js';
|
|
5
|
+
import { SettingsRepository } from '../repositories/settings.repository.js';
|
|
6
|
+
import { ShowsMetaRepository } from '../repositories/shows-meta.repository.js';
|
|
7
|
+
import { performWriteTransactionAsync } from '../sync.js';
|
|
8
|
+
import logger from '../logger.js';
|
|
9
|
+
let activeImportStatus = {
|
|
10
|
+
running: false,
|
|
11
|
+
current: 0,
|
|
12
|
+
total: 0,
|
|
13
|
+
phase: 'done',
|
|
14
|
+
source: null,
|
|
15
|
+
};
|
|
16
|
+
let activeImportCancelled = false;
|
|
17
|
+
export function getMalImportStatus() {
|
|
18
|
+
return activeImportStatus;
|
|
19
|
+
}
|
|
20
|
+
export function requestMalImportCancel() {
|
|
21
|
+
if (!activeImportStatus.running) {
|
|
22
|
+
return { success: true, message: 'No import running' };
|
|
23
|
+
}
|
|
24
|
+
activeImportCancelled = true;
|
|
25
|
+
return { success: true, message: 'Import cancellation requested' };
|
|
26
|
+
}
|
|
27
|
+
export function prepareMalImport(fileBuffer, flags) {
|
|
28
|
+
if (activeImportStatus.running) {
|
|
29
|
+
return { ok: false, status: 409, body: { error: 'An import is already in progress' } };
|
|
30
|
+
}
|
|
31
|
+
if (!fileBuffer)
|
|
32
|
+
return { ok: false, status: 400, body: { error: 'No file' } };
|
|
33
|
+
const { erase, useOfflineDb, skipFallback } = flags;
|
|
34
|
+
const offlineEnabled = useOfflineDb !== 'false' && useOfflineDb !== false && useOfflineDb !== '0';
|
|
35
|
+
const shouldSkipFallback = skipFallback === 'true' || skipFallback === true || skipFallback === '1';
|
|
36
|
+
let result;
|
|
37
|
+
try {
|
|
38
|
+
result = malXmlParser.parse(fileBuffer.toString());
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return { ok: false, status: 400, body: { error: 'Invalid XML' } };
|
|
42
|
+
}
|
|
43
|
+
const animeList = result?.myanimelist?.anime || [];
|
|
44
|
+
if (animeList.length === 0) {
|
|
45
|
+
return { ok: false, status: 400, body: { error: 'No anime found in XML' } };
|
|
46
|
+
}
|
|
47
|
+
activeImportCancelled = false;
|
|
48
|
+
activeImportStatus = {
|
|
49
|
+
running: true,
|
|
50
|
+
current: 0,
|
|
51
|
+
total: animeList.length,
|
|
52
|
+
phase: 'offline',
|
|
53
|
+
source: 'offline',
|
|
54
|
+
};
|
|
55
|
+
return { ok: true, import: { animeList, erase, offlineEnabled, shouldSkipFallback } };
|
|
56
|
+
}
|
|
57
|
+
export async function executeMalImport(db, prepared, sendEvent) {
|
|
58
|
+
const { animeList, erase, offlineEnabled, shouldSkipFallback } = prepared;
|
|
59
|
+
const total = animeList.length;
|
|
60
|
+
let skippedCount = 0;
|
|
61
|
+
const offlineShows = [];
|
|
62
|
+
const offlineMeta = [];
|
|
63
|
+
const fallbackQueue = [];
|
|
64
|
+
for (const item of animeList) {
|
|
65
|
+
const malTitle = xmlText(item.series_title);
|
|
66
|
+
const status = mapMalStatus(xmlText(item.my_status));
|
|
67
|
+
let matched = false;
|
|
68
|
+
if (offlineEnabled) {
|
|
69
|
+
const malId = parseInt(xmlText(item.series_animedb_id), 10);
|
|
70
|
+
if (malId && !Number.isNaN(malId)) {
|
|
71
|
+
const entry = offlineDb.getByMalId(malId);
|
|
72
|
+
if (entry) {
|
|
73
|
+
matched = true;
|
|
74
|
+
const showId = String(entry.anilistId);
|
|
75
|
+
const title = entry.title || malTitle;
|
|
76
|
+
offlineShows.push({ id: showId, name: title, thumbnail: entry.thumbnail, status });
|
|
77
|
+
if (entry.thumbnail || entry.type || entry.genres) {
|
|
78
|
+
offlineMeta.push({
|
|
79
|
+
id: showId,
|
|
80
|
+
thumbnail: entry.thumbnail,
|
|
81
|
+
type: entry.type,
|
|
82
|
+
genres: entry.genres,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
activeImportStatus = {
|
|
86
|
+
running: true,
|
|
87
|
+
current: offlineShows.length,
|
|
88
|
+
total,
|
|
89
|
+
phase: 'offline',
|
|
90
|
+
source: 'offline',
|
|
91
|
+
title: malTitle,
|
|
92
|
+
matchedTitle: title,
|
|
93
|
+
status,
|
|
94
|
+
found: true,
|
|
95
|
+
imported: offlineShows.length,
|
|
96
|
+
};
|
|
97
|
+
sendEvent('progress', {
|
|
98
|
+
current: offlineShows.length,
|
|
99
|
+
total,
|
|
100
|
+
title: malTitle,
|
|
101
|
+
matchedTitle: title,
|
|
102
|
+
status,
|
|
103
|
+
source: 'offline',
|
|
104
|
+
found: true,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!matched)
|
|
110
|
+
fallbackQueue.push(item);
|
|
111
|
+
}
|
|
112
|
+
if (offlineShows.length > 0 || erase) {
|
|
113
|
+
try {
|
|
114
|
+
await performWriteTransactionAsync(db, async (tx) => {
|
|
115
|
+
if (erase)
|
|
116
|
+
await SettingsRepository.clearWatchlist(tx);
|
|
117
|
+
await SettingsRepository.upsertWatchlistBatch(tx, offlineShows);
|
|
118
|
+
for (const meta of offlineMeta) {
|
|
119
|
+
if (meta.thumbnail || meta.type || meta.genres) {
|
|
120
|
+
await ShowsMetaRepository.upsert(tx, {
|
|
121
|
+
id: meta.id,
|
|
122
|
+
thumbnail: meta.thumbnail,
|
|
123
|
+
type: meta.type,
|
|
124
|
+
genres: meta.genres,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch (dbErr) {
|
|
131
|
+
logger.error({ err: dbErr }, 'Failed to commit offline import matches');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
let fallbackImported = 0;
|
|
135
|
+
if (shouldSkipFallback || activeImportCancelled) {
|
|
136
|
+
skippedCount += fallbackQueue.length;
|
|
137
|
+
}
|
|
138
|
+
else if (fallbackQueue.length > 0) {
|
|
139
|
+
activeImportStatus = { ...activeImportStatus, phase: 'fallback', source: null };
|
|
140
|
+
const BATCH_SIZE = 5;
|
|
141
|
+
for (let i = 0; i < fallbackQueue.length; i += BATCH_SIZE) {
|
|
142
|
+
if (activeImportCancelled) {
|
|
143
|
+
skippedCount += fallbackQueue.length - i;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
const batch = fallbackQueue.slice(i, i + BATCH_SIZE);
|
|
147
|
+
const batchResults = await Promise.allSettled(batch.map((item) => searchByTitleForMal(xmlText(item.series_title))));
|
|
148
|
+
const batchShows = [];
|
|
149
|
+
const batchMeta = [];
|
|
150
|
+
const metaPromises = [];
|
|
151
|
+
batchResults.forEach((r, idx) => {
|
|
152
|
+
const malTitle = xmlText(batch[idx].series_title);
|
|
153
|
+
const status = mapMalStatus(xmlText(batch[idx].my_status));
|
|
154
|
+
const current = offlineShows.length + fallbackImported + idx + 1;
|
|
155
|
+
if (r.status === 'fulfilled' && r.value) {
|
|
156
|
+
const show = r.value;
|
|
157
|
+
const title = show.title?.english || show.title?.romaji || malTitle;
|
|
158
|
+
batchShows.push({ id: String(show.id), name: title, status });
|
|
159
|
+
activeImportStatus = {
|
|
160
|
+
running: true,
|
|
161
|
+
current,
|
|
162
|
+
total,
|
|
163
|
+
phase: 'fallback',
|
|
164
|
+
source: show.source,
|
|
165
|
+
title: malTitle,
|
|
166
|
+
matchedTitle: title,
|
|
167
|
+
status,
|
|
168
|
+
found: true,
|
|
169
|
+
imported: offlineShows.length + fallbackImported + 1,
|
|
170
|
+
};
|
|
171
|
+
sendEvent('progress', {
|
|
172
|
+
current,
|
|
173
|
+
total,
|
|
174
|
+
title: malTitle,
|
|
175
|
+
matchedTitle: title,
|
|
176
|
+
status,
|
|
177
|
+
source: show.source,
|
|
178
|
+
found: true,
|
|
179
|
+
});
|
|
180
|
+
metaPromises.push(getShowMetaById(String(show.id))
|
|
181
|
+
.then((meta) => {
|
|
182
|
+
if (meta) {
|
|
183
|
+
batchMeta.push({
|
|
184
|
+
id: String(show.id),
|
|
185
|
+
thumbnail: meta.thumbnail || undefined,
|
|
186
|
+
type: meta.type || undefined,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
.catch(() => { }));
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
skippedCount++;
|
|
194
|
+
activeImportStatus = {
|
|
195
|
+
running: true,
|
|
196
|
+
current,
|
|
197
|
+
total,
|
|
198
|
+
phase: 'fallback',
|
|
199
|
+
source: null,
|
|
200
|
+
title: malTitle,
|
|
201
|
+
matchedTitle: null,
|
|
202
|
+
status,
|
|
203
|
+
found: false,
|
|
204
|
+
imported: offlineShows.length + fallbackImported,
|
|
205
|
+
};
|
|
206
|
+
sendEvent('progress', {
|
|
207
|
+
current,
|
|
208
|
+
total,
|
|
209
|
+
title: malTitle,
|
|
210
|
+
matchedTitle: null,
|
|
211
|
+
status,
|
|
212
|
+
source: null,
|
|
213
|
+
found: false,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
await Promise.allSettled(metaPromises);
|
|
218
|
+
if (batchShows.length > 0) {
|
|
219
|
+
try {
|
|
220
|
+
await performWriteTransactionAsync(db, async (tx) => {
|
|
221
|
+
await SettingsRepository.upsertWatchlistBatch(tx, batchShows);
|
|
222
|
+
for (const meta of batchMeta) {
|
|
223
|
+
if (meta.thumbnail) {
|
|
224
|
+
await ShowsMetaRepository.upsert(tx, {
|
|
225
|
+
id: meta.id,
|
|
226
|
+
thumbnail: meta.thumbnail,
|
|
227
|
+
type: meta.type,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
fallbackImported += batchShows.length;
|
|
233
|
+
}
|
|
234
|
+
catch (dbErr) {
|
|
235
|
+
logger.error({ err: dbErr }, 'Failed to commit fallback import batch');
|
|
236
|
+
skippedCount += batchShows.length;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const imported = offlineShows.length + fallbackImported;
|
|
242
|
+
activeImportStatus = {
|
|
243
|
+
running: false,
|
|
244
|
+
current: total,
|
|
245
|
+
total,
|
|
246
|
+
phase: 'done',
|
|
247
|
+
source: null,
|
|
248
|
+
imported,
|
|
249
|
+
skipped: skippedCount,
|
|
250
|
+
};
|
|
251
|
+
sendEvent('complete', { imported, skipped: skippedCount });
|
|
252
|
+
}
|
|
253
|
+
function xmlText(value) {
|
|
254
|
+
if (Array.isArray(value))
|
|
255
|
+
return String(value[0] ?? '');
|
|
256
|
+
return String(value ?? '');
|
|
257
|
+
}
|
|
258
|
+
function mapMalStatus(malStatus) {
|
|
259
|
+
switch (malStatus) {
|
|
260
|
+
case 'Plan to Watch':
|
|
261
|
+
return 'Planned';
|
|
262
|
+
case 'On Hold':
|
|
263
|
+
return 'On-Hold';
|
|
264
|
+
default:
|
|
265
|
+
return malStatus;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const malXmlParser = new XMLParser({
|
|
269
|
+
isArray: (name) => name === 'anime',
|
|
270
|
+
parseTagValue: false,
|
|
271
|
+
trimValues: true,
|
|
272
|
+
});
|
|
273
|
+
async function searchByTitleForMal(title) {
|
|
274
|
+
const result = await searchAnilistByTitle(title);
|
|
275
|
+
if (result)
|
|
276
|
+
return { ...result, source: 'anilist' };
|
|
277
|
+
if (isAnilistRateLimited()) {
|
|
278
|
+
logger.debug({ title }, 'AniList rate limited, trying Kitsu fallback for MAL import');
|
|
279
|
+
const fb = await kitsuSearchAnime({ query: title, page: 1, perPage: 5 });
|
|
280
|
+
if (fb.length > 0) {
|
|
281
|
+
const lowerTitle = title.toLowerCase();
|
|
282
|
+
const exactMatch = fb.find((m) => m.title?.romaji?.toLowerCase() === lowerTitle ||
|
|
283
|
+
m.title?.english?.toLowerCase() === lowerTitle);
|
|
284
|
+
const best = exactMatch || fb[0];
|
|
285
|
+
if (best.id > 0) {
|
|
286
|
+
return { id: best.id, title: best.title ?? {}, source: 'kitsu' };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|