@serifpersia/dango 3.3.9 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/client/dist/assets/{AnimeCard-URCiiy5W.js → AnimeCard-CJy1SXH3.js} +1 -1
- package/client/dist/assets/{AnimeInfo-e3z3er9t.js → AnimeInfo-BoXlVvZ-.js} +1 -1
- package/client/dist/assets/{AnimeInfoPage-B7Djlmy_.js → AnimeInfoPage-BxbA_7AV.js} +2 -2
- package/client/dist/assets/Asmr--Zc8G9zv.js +2 -0
- package/client/dist/assets/AsmrResetProgressModal-CKYm-8cp.js +1 -0
- package/client/dist/assets/AvSyncCalibrator-3yjZS7s6.js +10 -0
- package/client/dist/assets/Home-rrhOGQcg.js +1 -0
- package/client/dist/assets/Insights-Bay90Yeg.js +1 -0
- package/client/dist/assets/{LibraryListPage-B0qWqhb_.js → LibraryListPage-HzuDavbb.js} +1 -1
- package/client/dist/assets/{ListeningList-CSqD8j1C.js → ListeningList-DYcQnnsG.js} +1 -1
- package/client/dist/assets/{Manga-Cg2YuObF.js → Manga-D8CT9aO0.js} +1 -1
- package/client/dist/assets/{MangaInfoPage-DcArhEsy.js → MangaInfoPage-DGK6CYp1.js} +1 -1
- package/client/dist/assets/MangaReadPage-B9XoDKF9.js +1 -0
- package/client/dist/assets/{MangaResetProgressModal-D_yT3d1v.js → MangaResetProgressModal-QjZyIAjl.js} +1 -1
- package/client/dist/assets/Mature-CHt_-Hz5.js +1 -0
- package/client/dist/assets/MediaCard-B5584Cq9.js +1 -0
- package/client/dist/assets/Music-HeLrEZ2d.js +1 -0
- package/client/dist/assets/OptionListMenu-CJ90CdwI.js +1 -0
- package/client/dist/assets/Player-BYtEax_z.js +56 -0
- package/client/dist/assets/PlayerSettings-4-cUAY4B.js +1 -0
- package/client/dist/assets/QueueOptionsButton-Ba0dg_82.js +1 -0
- package/client/dist/assets/{QueueRail-B7f1-e6i.js → QueueRail-O7f4RqjB.js} +1 -1
- package/client/dist/assets/Radio-DPZxQnyD.js +1 -0
- package/client/dist/assets/{ReadingList-BaIem8H1.js → ReadingList-n6rr8ygy.js} +1 -1
- package/client/dist/assets/{ResetProgressModal-DES5Y-Fl.js → ResetProgressModal-CcStlbhP.js} +1 -1
- package/client/dist/assets/Search-C2wrWOdy.js +1 -0
- package/client/dist/assets/Settings-CluUYs8U.js +21 -0
- package/client/dist/assets/{SkeletonGrid-BxfgjP4i.js → SkeletonGrid-CLEPY9nP.js} +1 -1
- package/client/dist/assets/Trackers-CTGPxs0V.js +2 -0
- package/client/dist/assets/Tv-BwHlDdi8.js +4 -0
- package/client/dist/assets/Tv-ZEA45gXK.css +1 -0
- package/client/dist/assets/TvInfo-CXnrLekr.js +1 -0
- package/client/dist/assets/TvPopup-BekfiNcX.js +1 -0
- package/client/dist/assets/{TvSearch-BWDwWnHe.js → TvSearch-DuqR-z3v.js} +1 -1
- package/client/dist/assets/{TvWatchlist-D3n5_4MI.js → TvWatchlist-CBvgFrA0.js} +1 -1
- package/client/dist/assets/Watchlist-mj8mTOam.js +1 -0
- package/client/dist/assets/{hls-BO_L1WoN.js → hls-Boje3IOE.js} +1 -1
- package/client/dist/assets/index-BXwhFWB_.js +178 -0
- package/client/dist/assets/useAnimeInfoData-DJ7A4B4T.js +1 -0
- package/client/dist/assets/useAsmr-DIwIsJXF.js +1 -0
- package/client/dist/assets/{useAsmrPopup-D_o9-QlR.js → useAsmrPopup-e4zNN9ig.js} +1 -1
- package/client/dist/assets/useMangaLibrary-C9Lci5N4.js +1 -0
- package/client/dist/assets/{useMangaPopup-TFzkC-tG.js → useMangaPopup-B8nnpvyi.js} +1 -1
- package/client/dist/assets/useProviders-B5Qr4I1y.js +1 -0
- package/client/dist/assets/useTv-Dm8MCYlL.js +1 -0
- package/client/dist/assets/useTvLibrary-B7jEk2Q7.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 +2 -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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { protectedProcedure, router } from '../index.js';
|
|
2
|
+
import logger from '../../logger.js';
|
|
3
|
+
import { performWriteTransactionAsync } from '../../sync.js';
|
|
4
|
+
import { WatchlistRepository } from '../../repositories/watchlist.repository.js';
|
|
5
|
+
import { WatchedEpisodesRepository } from '../../repositories/watched-episodes.repository.js';
|
|
6
|
+
import { NotificationsRepository } from '../../repositories/notifications.repository.js';
|
|
7
|
+
import { notificationDismissInput } from '../validation.js';
|
|
8
|
+
function clearAllInput() {
|
|
9
|
+
return {
|
|
10
|
+
'~standard': {
|
|
11
|
+
version: 1,
|
|
12
|
+
vendor: 'dango',
|
|
13
|
+
types: undefined,
|
|
14
|
+
validate(value) {
|
|
15
|
+
try {
|
|
16
|
+
if (value === undefined || value === null)
|
|
17
|
+
return { value: {} };
|
|
18
|
+
if (typeof value !== 'object' || value === null)
|
|
19
|
+
throw new Error('Expected an object');
|
|
20
|
+
const showId = value.showId;
|
|
21
|
+
if (showId === undefined || showId === null)
|
|
22
|
+
return { value: {} };
|
|
23
|
+
if (typeof showId !== 'string' || showId.length === 0)
|
|
24
|
+
throw new Error('showId must be a string');
|
|
25
|
+
return { value: { showId } };
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
return { issues: [{ message: err instanceof Error ? err.message : 'Invalid input' }] };
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export const notificationsRouter = router({
|
|
35
|
+
list: protectedProcedure.query(async ({ ctx }) => {
|
|
36
|
+
const watchingShows = await WatchlistRepository.getWatchingShows(ctx.db);
|
|
37
|
+
const notifications = [];
|
|
38
|
+
for (const show of watchingShows) {
|
|
39
|
+
try {
|
|
40
|
+
const [watchedEps, dismissedEps, discoveredEps] = await Promise.all([
|
|
41
|
+
WatchedEpisodesRepository.getWatchedEpisodeNumbers(ctx.db, show.id),
|
|
42
|
+
NotificationsRepository.getDismissedByShow(ctx.db, show.id),
|
|
43
|
+
NotificationsRepository.getDiscoveredByShow(ctx.db, show.id),
|
|
44
|
+
]);
|
|
45
|
+
const watchedSet = new Set(watchedEps.map((e) => e.toString()));
|
|
46
|
+
const dismissedSet = new Set(dismissedEps.map((e) => e.episodeNumber.toString()));
|
|
47
|
+
for (const discovered of discoveredEps) {
|
|
48
|
+
if (!watchedSet.has(discovered.episodeNumber) &&
|
|
49
|
+
!dismissedSet.has(discovered.episodeNumber)) {
|
|
50
|
+
notifications.push({
|
|
51
|
+
showId: show.id,
|
|
52
|
+
name: show.name,
|
|
53
|
+
nativeName: show.nativeName,
|
|
54
|
+
englishName: show.englishName,
|
|
55
|
+
thumbnail: show.thumbnail,
|
|
56
|
+
episodeNumber: discovered.episodeNumber,
|
|
57
|
+
id: `${show.id}-${discovered.episodeNumber}`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
logger.error({ err: e, showId: show.id }, 'Failed to get notifications for show');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return notifications.sort((a, b) => parseFloat(b.episodeNumber) - parseFloat(a.episodeNumber));
|
|
67
|
+
}),
|
|
68
|
+
dismiss: protectedProcedure.input(notificationDismissInput()).mutation(async ({ ctx, input }) => {
|
|
69
|
+
await performWriteTransactionAsync(ctx.db, async (tx) => {
|
|
70
|
+
await NotificationsRepository.addDismissed(tx, input.showId, input.episodeNumber);
|
|
71
|
+
});
|
|
72
|
+
return { success: true };
|
|
73
|
+
}),
|
|
74
|
+
clearAll: protectedProcedure.input(clearAllInput()).mutation(async ({ ctx, input }) => {
|
|
75
|
+
await performWriteTransactionAsync(ctx.db, async (tx) => {
|
|
76
|
+
await NotificationsRepository.dismissFromDiscovered(tx, input.showId);
|
|
77
|
+
});
|
|
78
|
+
return { success: true };
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { protectedProcedure, router } from '../index.js';
|
|
2
|
+
import { WatchedEpisodesRepository } from '../../repositories/watched-episodes.repository.js';
|
|
3
|
+
import { getMigratedId } from '../../lib/migration.js';
|
|
4
|
+
import { episodeProgressInput, showIdInput } from '../validation.js';
|
|
5
|
+
export const progressRouter = router({
|
|
6
|
+
getEpisode: protectedProcedure.input(episodeProgressInput()).query(async ({ ctx, input }) => {
|
|
7
|
+
const showId = await getMigratedId(ctx.db, input.showId);
|
|
8
|
+
const progress = await WatchedEpisodesRepository.getByShowAndEpisode(ctx.db, showId, input.episodeNumber);
|
|
9
|
+
return progress || { currentTime: 0, duration: 0 };
|
|
10
|
+
}),
|
|
11
|
+
getWatchedEpisodes: protectedProcedure.input(showIdInput()).query(async ({ ctx, input }) => {
|
|
12
|
+
const showId = await getMigratedId(ctx.db, input.showId);
|
|
13
|
+
return WatchedEpisodesRepository.getWatchedEpisodeNumbers(ctx.db, showId);
|
|
14
|
+
}),
|
|
15
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { protectedProcedure, router } from '../index.js';
|
|
2
|
+
import { QueueRepository } from '../../repositories/queue.repository.js';
|
|
3
|
+
import { ShowsMetaRepository } from '../../repositories/shows-meta.repository.js';
|
|
4
|
+
import { performWriteTransactionAsync } from '../../sync.js';
|
|
5
|
+
import { getMigratedId } from '../../lib/migration.js';
|
|
6
|
+
import { queueAddInput, queueBatchInput, queueRemoveInput, queueRemoveManyInput, queueReorderInput, } from '../validation.js';
|
|
7
|
+
function applyMeta(meta) {
|
|
8
|
+
return meta.showName || meta.showThumbnail || meta.nativeName || meta.englishName || meta.type;
|
|
9
|
+
}
|
|
10
|
+
export const queueRouter = router({
|
|
11
|
+
list: protectedProcedure.query(async ({ ctx }) => {
|
|
12
|
+
const rows = await QueueRepository.getAll(ctx.db);
|
|
13
|
+
return rows.map((row) => ({
|
|
14
|
+
...row,
|
|
15
|
+
_id: row.showId,
|
|
16
|
+
id: row.id,
|
|
17
|
+
thumbnail: row.thumbnail || '',
|
|
18
|
+
}));
|
|
19
|
+
}),
|
|
20
|
+
add: protectedProcedure.input(queueAddInput()).mutation(async ({ ctx, input }) => {
|
|
21
|
+
const showId = await getMigratedId(ctx.db, input.showId);
|
|
22
|
+
const episodeNumber = input.episodeNumber;
|
|
23
|
+
const existing = await QueueRepository.getByEpisode(ctx.db, showId, episodeNumber);
|
|
24
|
+
await performWriteTransactionAsync(ctx.db, async (tx) => {
|
|
25
|
+
if (applyMeta(input)) {
|
|
26
|
+
await ShowsMetaRepository.upsert(tx, {
|
|
27
|
+
id: showId,
|
|
28
|
+
name: input.showName || '',
|
|
29
|
+
thumbnail: input.showThumbnail || '',
|
|
30
|
+
nativeName: input.nativeName,
|
|
31
|
+
englishName: input.englishName,
|
|
32
|
+
type: input.type,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (existing) {
|
|
36
|
+
await QueueRepository.removeEpisode(tx, showId, episodeNumber);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
await QueueRepository.addToEnd(tx, showId, episodeNumber);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
ctx.db.scheduleSave();
|
|
43
|
+
return { success: true, queued: !existing };
|
|
44
|
+
}),
|
|
45
|
+
addBatch: protectedProcedure.input(queueBatchInput()).mutation(async ({ ctx, input }) => {
|
|
46
|
+
const showId = await getMigratedId(ctx.db, input.showId);
|
|
47
|
+
const normalized = [...new Set(input.episodeNumbers)];
|
|
48
|
+
await performWriteTransactionAsync(ctx.db, async (tx) => {
|
|
49
|
+
if (applyMeta(input)) {
|
|
50
|
+
await ShowsMetaRepository.upsert(tx, {
|
|
51
|
+
id: showId,
|
|
52
|
+
name: input.showName || '',
|
|
53
|
+
thumbnail: input.showThumbnail || '',
|
|
54
|
+
nativeName: input.nativeName,
|
|
55
|
+
englishName: input.englishName,
|
|
56
|
+
type: input.type,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
await QueueRepository.addManyToEnd(tx, normalized.map((episodeNumber) => ({ showId, episodeNumber })));
|
|
60
|
+
});
|
|
61
|
+
ctx.db.scheduleSave();
|
|
62
|
+
return { success: true, added: normalized.length };
|
|
63
|
+
}),
|
|
64
|
+
remove: protectedProcedure.input(queueRemoveInput()).mutation(async ({ ctx, input }) => {
|
|
65
|
+
const showId = await getMigratedId(ctx.db, input.showId);
|
|
66
|
+
await performWriteTransactionAsync(ctx.db, async (tx) => {
|
|
67
|
+
await QueueRepository.removeEpisode(tx, showId, input.episodeNumber);
|
|
68
|
+
});
|
|
69
|
+
return { success: true };
|
|
70
|
+
}),
|
|
71
|
+
removeMany: protectedProcedure.input(queueRemoveManyInput()).mutation(async ({ ctx, input }) => {
|
|
72
|
+
const showId = await getMigratedId(ctx.db, input.showId);
|
|
73
|
+
let removed;
|
|
74
|
+
await performWriteTransactionAsync(ctx.db, async (tx) => {
|
|
75
|
+
removed = ((await QueueRepository.getByShow(tx, showId)) || []).map((ep) => ep.episodeNumber);
|
|
76
|
+
const toRemove = Array.isArray(input.episodeNumbers) && input.episodeNumbers.length
|
|
77
|
+
? [...new Set(input.episodeNumbers)]
|
|
78
|
+
: removed;
|
|
79
|
+
await QueueRepository.removeMany(tx, showId, toRemove);
|
|
80
|
+
});
|
|
81
|
+
return { success: true, removed: removed.length };
|
|
82
|
+
}),
|
|
83
|
+
clear: protectedProcedure.mutation(async ({ ctx }) => {
|
|
84
|
+
await performWriteTransactionAsync(ctx.db, async (tx) => {
|
|
85
|
+
await QueueRepository.clear(tx);
|
|
86
|
+
});
|
|
87
|
+
return { success: true };
|
|
88
|
+
}),
|
|
89
|
+
reorder: protectedProcedure.input(queueReorderInput()).mutation(async ({ ctx, input }) => {
|
|
90
|
+
await performWriteTransactionAsync(ctx.db, async (tx) => {
|
|
91
|
+
await QueueRepository.reorder(tx, input);
|
|
92
|
+
});
|
|
93
|
+
return { success: true };
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
1
|
+
import { protectedProcedure, router } from '../index.js';
|
|
2
|
+
import { defineSchema, optStr, reqObj } from '../validation.js';
|
|
3
|
+
import logger from '../../logger.js';
|
|
4
|
+
import { parseJsonBody } from '../../utils/http.utils.js';
|
|
4
5
|
const LISTEN_MOE_STATIONS = [
|
|
5
6
|
{
|
|
6
7
|
id: 'listenmoe-jpop',
|
|
@@ -96,43 +97,49 @@ function toStation(s) {
|
|
|
96
97
|
source: 'radio-browser',
|
|
97
98
|
};
|
|
98
99
|
}
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
100
|
+
const radioSearchInput = () => defineSchema((value) => {
|
|
101
|
+
const obj = reqObj(value);
|
|
102
|
+
const out = {};
|
|
103
|
+
const q = optStr(obj, 'q');
|
|
104
|
+
if (q !== undefined)
|
|
105
|
+
out.q = q;
|
|
106
|
+
return out;
|
|
107
|
+
});
|
|
108
|
+
export const radioRouter = router({
|
|
109
|
+
stations: protectedProcedure.query(async ({ ctx }) => {
|
|
102
110
|
try {
|
|
103
|
-
const cached = apiCache.get('route-radio-stations');
|
|
111
|
+
const cached = ctx.apiCache.get('route-radio-stations');
|
|
104
112
|
if (cached)
|
|
105
|
-
return
|
|
113
|
+
return { stations: cached };
|
|
106
114
|
const stations = [...LISTEN_MOE_STATIONS, ...FEATURED_STATIONS];
|
|
107
|
-
apiCache.set('route-radio-stations', stations, 3600);
|
|
108
|
-
|
|
115
|
+
ctx.apiCache.set('route-radio-stations', stations, 3600);
|
|
116
|
+
return { stations };
|
|
109
117
|
}
|
|
110
118
|
catch (err) {
|
|
111
119
|
logger.error({ err }, '[Radio] stations failed');
|
|
112
|
-
|
|
120
|
+
return { stations: LISTEN_MOE_STATIONS };
|
|
113
121
|
}
|
|
114
|
-
})
|
|
115
|
-
|
|
122
|
+
}),
|
|
123
|
+
search: protectedProcedure.input(radioSearchInput()).query(async ({ ctx, input }) => {
|
|
116
124
|
try {
|
|
117
|
-
const q = String(
|
|
125
|
+
const q = String(input.q || '').trim();
|
|
118
126
|
if (!q)
|
|
119
|
-
return
|
|
127
|
+
return { stations: [] };
|
|
120
128
|
const cacheKey = `route-radio-search-${q.toLowerCase()}`;
|
|
121
|
-
const cached = apiCache.get(cacheKey);
|
|
129
|
+
const cached = ctx.apiCache.get(cacheKey);
|
|
122
130
|
if (cached)
|
|
123
|
-
return
|
|
131
|
+
return { stations: cached };
|
|
124
132
|
const raw = (await rbFetch(`/json/stations/search?name=${encodeURIComponent(q)}&hidebroken=true&order=clickcount&reverse=true&limit=25`));
|
|
125
133
|
const stations = raw
|
|
126
134
|
.map(toStation)
|
|
127
135
|
.filter((s) => s !== null)
|
|
128
136
|
.slice(0, 25);
|
|
129
|
-
apiCache.set(cacheKey, stations, 300);
|
|
130
|
-
|
|
137
|
+
ctx.apiCache.set(cacheKey, stations, 300);
|
|
138
|
+
return { stations };
|
|
131
139
|
}
|
|
132
140
|
catch (err) {
|
|
133
141
|
logger.error({ err }, '[Radio] search failed');
|
|
134
|
-
|
|
142
|
+
return { stations: [] };
|
|
135
143
|
}
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
}
|
|
144
|
+
}),
|
|
145
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
|
2
|
+
import { protectedProcedure, router } from '../index.js';
|
|
3
|
+
import { SettingsRepository } from '../../repositories/settings.repository.js';
|
|
4
|
+
import { performWriteTransactionAsync } from '../../sync.js';
|
|
5
|
+
import { discordRPCService } from '../../discord-rpc.js';
|
|
6
|
+
import logger from '../../logger.js';
|
|
7
|
+
import { offlineDb } from '../../lib/offline-db.js';
|
|
8
|
+
import { translateTexts } from '../../lib/translate.js';
|
|
9
|
+
import { getMachineId } from '../../utils/machine-id.js';
|
|
10
|
+
import { getMalImportStatus, requestMalImportCancel } from '../../lib/mal-import.js';
|
|
11
|
+
import { defineSchema, reqObj } from '../validation.js';
|
|
12
|
+
import { keyInput, setSettingInput } from '../validation.js';
|
|
13
|
+
export const SETTING_DEFAULTS = {
|
|
14
|
+
discordRPCEnabled: 'true',
|
|
15
|
+
discordRPCHideMature: 'true',
|
|
16
|
+
ignoreAdultContent: 'true',
|
|
17
|
+
mangaIgnoreAdultContent: 'true',
|
|
18
|
+
tvIgnoreAdultContent: 'true',
|
|
19
|
+
asmrIgnoreAdultContent: 'true',
|
|
20
|
+
};
|
|
21
|
+
const autoUpdateInput = () => defineSchema((value) => {
|
|
22
|
+
const obj = reqObj(value);
|
|
23
|
+
if (typeof obj.enabled !== 'boolean')
|
|
24
|
+
throw new Error('enabled must be a boolean');
|
|
25
|
+
return { enabled: obj.enabled };
|
|
26
|
+
});
|
|
27
|
+
const translateInput = () => defineSchema((value) => {
|
|
28
|
+
if (value === undefined || value === null)
|
|
29
|
+
return {};
|
|
30
|
+
const obj = reqObj(value);
|
|
31
|
+
return { texts: obj.texts, source: obj.source, target: obj.target };
|
|
32
|
+
});
|
|
33
|
+
export const settingsRouter = router({
|
|
34
|
+
getByKey: protectedProcedure.input(keyInput()).query(async ({ ctx, input }) => {
|
|
35
|
+
const row = await SettingsRepository.getByKey(ctx.db, input.key);
|
|
36
|
+
const value = row ? row.value : (SETTING_DEFAULTS[input.key] ?? null);
|
|
37
|
+
return { value };
|
|
38
|
+
}),
|
|
39
|
+
set: protectedProcedure.input(setSettingInput()).mutation(async ({ ctx, input }) => {
|
|
40
|
+
const key = input.key;
|
|
41
|
+
const value = String(input.value ?? '');
|
|
42
|
+
const shouldDelete = value === '' && key === 'tracker_anilist_client_id';
|
|
43
|
+
await performWriteTransactionAsync(ctx.db, async (tx) => {
|
|
44
|
+
if (shouldDelete)
|
|
45
|
+
await SettingsRepository.deleteByKey(tx, key);
|
|
46
|
+
else
|
|
47
|
+
await SettingsRepository.upsert(tx, key, value);
|
|
48
|
+
});
|
|
49
|
+
if (key === 'discordRPCEnabled') {
|
|
50
|
+
discordRPCService.setEnabled(input.value === 'true' || input.value === true);
|
|
51
|
+
}
|
|
52
|
+
if (key === 'discordRPCHideMature') {
|
|
53
|
+
discordRPCService.setHideMature(input.value === 'true' || input.value === true);
|
|
54
|
+
}
|
|
55
|
+
return { success: true };
|
|
56
|
+
}),
|
|
57
|
+
offlineDbInfo: protectedProcedure.query(async ({ ctx }) => {
|
|
58
|
+
return await offlineDb.getOfflineDbInfo(ctx.db);
|
|
59
|
+
}),
|
|
60
|
+
setOfflineDbAutoUpdate: protectedProcedure
|
|
61
|
+
.input(autoUpdateInput())
|
|
62
|
+
.mutation(async ({ ctx, input }) => {
|
|
63
|
+
await SettingsRepository.upsert(ctx.db, 'offlineDbAutoUpdateEnabled', String(input.enabled));
|
|
64
|
+
return { success: true, enabled: input.enabled };
|
|
65
|
+
}),
|
|
66
|
+
triggerOfflineDbUpdate: protectedProcedure.mutation(async ({ ctx }) => {
|
|
67
|
+
const info = await offlineDb.getOfflineDbInfo(ctx.db);
|
|
68
|
+
if (info.isRefreshing) {
|
|
69
|
+
throw new TRPCError({
|
|
70
|
+
code: 'CONFLICT',
|
|
71
|
+
message: 'Offline database refresh already in progress',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
offlineDb.refreshDatabase(ctx.db).catch((err) => {
|
|
75
|
+
logger.warn({ err: err?.message }, 'Manual offline database update failed');
|
|
76
|
+
});
|
|
77
|
+
return { message: 'Offline database refresh started' };
|
|
78
|
+
}),
|
|
79
|
+
installationId: protectedProcedure.query(() => {
|
|
80
|
+
try {
|
|
81
|
+
return { id: getMachineId() };
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
logger.error({ err }, 'Failed to get machine ID');
|
|
85
|
+
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to get machine ID' });
|
|
86
|
+
}
|
|
87
|
+
}),
|
|
88
|
+
translate: protectedProcedure.input(translateInput()).mutation(async ({ input }) => {
|
|
89
|
+
const texts = input.texts;
|
|
90
|
+
const source = input.source ?? 'ja';
|
|
91
|
+
const target = input.target ?? 'en';
|
|
92
|
+
if (!Array.isArray(texts) || texts.length === 0) {
|
|
93
|
+
return { translations: {} };
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
translations: await translateTexts(texts, String(source), String(target)),
|
|
97
|
+
};
|
|
98
|
+
}),
|
|
99
|
+
malImportStatus: protectedProcedure.query(() => {
|
|
100
|
+
return getMalImportStatus();
|
|
101
|
+
}),
|
|
102
|
+
malImportCancel: protectedProcedure.mutation(() => {
|
|
103
|
+
return requestMalImportCancel();
|
|
104
|
+
}),
|
|
105
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
|
2
|
+
import { protectedProcedure, router } from '../index.js';
|
|
3
|
+
import { SettingsRepository } from '../../repositories/settings.repository.js';
|
|
4
|
+
import { performWriteTransactionAsync } from '../../sync.js';
|
|
5
|
+
import { AniListTracker } from '../../lib/tracker/anilist-tracker.js';
|
|
6
|
+
import { syncAniList, importFromMalUsername, importFromUsername, } from '../../lib/tracker/sync.service.js';
|
|
7
|
+
import { syncAniListManga, importFromUsernameManga, importMangaFromMalUsername, } from '../../lib/tracker/manga-sync.service.js';
|
|
8
|
+
import { defineSchema, reqObj } from '../validation.js';
|
|
9
|
+
const TRACKER_TOKEN_KEY = 'tracker_anilist_token';
|
|
10
|
+
const TRACKER_USER_KEY = 'tracker_anilist_user';
|
|
11
|
+
function badRequest(message) {
|
|
12
|
+
return new TRPCError({ code: 'BAD_REQUEST', message });
|
|
13
|
+
}
|
|
14
|
+
function failed(message) {
|
|
15
|
+
return new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message });
|
|
16
|
+
}
|
|
17
|
+
function unavailable(message) {
|
|
18
|
+
return new TRPCError({ code: 'SERVICE_UNAVAILABLE', message });
|
|
19
|
+
}
|
|
20
|
+
function optBody(value) {
|
|
21
|
+
if (value === undefined || value === null)
|
|
22
|
+
return {};
|
|
23
|
+
return reqObj(value);
|
|
24
|
+
}
|
|
25
|
+
const trackerTokenInput = () => defineSchema((value) => {
|
|
26
|
+
const obj = optBody(value);
|
|
27
|
+
return { token: obj.token, code: obj.code, redirectUri: obj.redirectUri };
|
|
28
|
+
});
|
|
29
|
+
const trackerSyncInput = () => defineSchema((value) => ({
|
|
30
|
+
provider: optBody(value).provider,
|
|
31
|
+
}));
|
|
32
|
+
const trackerUsernameInput = () => defineSchema((value) => {
|
|
33
|
+
const obj = optBody(value);
|
|
34
|
+
return { username: obj.username, erase: obj.erase };
|
|
35
|
+
});
|
|
36
|
+
const trackerMalInput = () => defineSchema((value) => {
|
|
37
|
+
const obj = optBody(value);
|
|
38
|
+
return {
|
|
39
|
+
username: obj.username,
|
|
40
|
+
erase: obj.erase,
|
|
41
|
+
useOfflineDb: obj.useOfflineDb,
|
|
42
|
+
skipFallback: obj.skipFallback,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
const trackerMangaSyncInput = () => defineSchema((value) => ({
|
|
46
|
+
direction: optBody(value).direction,
|
|
47
|
+
}));
|
|
48
|
+
function mapImportError(message) {
|
|
49
|
+
if (message.includes('private') || message.includes('not found')) {
|
|
50
|
+
return new TRPCError({ code: 'NOT_FOUND', message });
|
|
51
|
+
}
|
|
52
|
+
if (message.includes('blocked') || message.includes('HTTP 429')) {
|
|
53
|
+
return new TRPCError({ code: 'TOO_MANY_REQUESTS', message });
|
|
54
|
+
}
|
|
55
|
+
return failed(message);
|
|
56
|
+
}
|
|
57
|
+
export const trackerRouter = router({
|
|
58
|
+
status: protectedProcedure.query(async ({ ctx }) => {
|
|
59
|
+
try {
|
|
60
|
+
const tokenRow = await SettingsRepository.getByKey(ctx.db, TRACKER_TOKEN_KEY);
|
|
61
|
+
const userRow = await SettingsRepository.getByKey(ctx.db, TRACKER_USER_KEY);
|
|
62
|
+
let user = null;
|
|
63
|
+
if (userRow?.value) {
|
|
64
|
+
try {
|
|
65
|
+
user = JSON.parse(userRow.value);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
user = null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { anilist: { connected: !!tokenRow?.value, user } };
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw failed('Failed to read tracker status');
|
|
75
|
+
}
|
|
76
|
+
}),
|
|
77
|
+
anilistAuth: protectedProcedure.input(trackerTokenInput()).mutation(async ({ ctx, input }) => {
|
|
78
|
+
const accessToken = typeof input.token === 'string' ? input.token.trim() : '';
|
|
79
|
+
if (!accessToken) {
|
|
80
|
+
throw badRequest('Access token is required');
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const tracker = new AniListTracker(accessToken);
|
|
84
|
+
const viewer = await tracker.getViewer();
|
|
85
|
+
await performWriteTransactionAsync(ctx.db, async (tx) => {
|
|
86
|
+
await SettingsRepository.upsert(tx, TRACKER_TOKEN_KEY, accessToken);
|
|
87
|
+
await SettingsRepository.upsert(tx, TRACKER_USER_KEY, JSON.stringify(viewer));
|
|
88
|
+
});
|
|
89
|
+
return { success: true, user: viewer };
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
throw new TRPCError({
|
|
93
|
+
code: 'UNAUTHORIZED',
|
|
94
|
+
message: err instanceof Error ? err.message : 'Authentication failed',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}),
|
|
98
|
+
anilistDisconnect: protectedProcedure.mutation(async ({ ctx }) => {
|
|
99
|
+
try {
|
|
100
|
+
await performWriteTransactionAsync(ctx.db, async (tx) => {
|
|
101
|
+
await SettingsRepository.upsert(tx, TRACKER_TOKEN_KEY, '');
|
|
102
|
+
await SettingsRepository.upsert(tx, TRACKER_USER_KEY, '');
|
|
103
|
+
});
|
|
104
|
+
return { success: true };
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw failed('Failed to disconnect');
|
|
108
|
+
}
|
|
109
|
+
}),
|
|
110
|
+
sync: protectedProcedure.input(trackerSyncInput()).mutation(async ({ ctx, input }) => {
|
|
111
|
+
const { provider = 'anilist' } = input;
|
|
112
|
+
if (provider !== 'anilist') {
|
|
113
|
+
throw badRequest(`Provider "${provider}" is not supported yet`);
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const summary = await syncAniList(ctx.db);
|
|
117
|
+
return { success: true, summary };
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
throw failed(err instanceof Error ? err.message : 'Sync failed');
|
|
121
|
+
}
|
|
122
|
+
}),
|
|
123
|
+
anilistImport: protectedProcedure
|
|
124
|
+
.input(trackerUsernameInput())
|
|
125
|
+
.mutation(async ({ ctx, input }) => {
|
|
126
|
+
const { username, erase } = input;
|
|
127
|
+
if (!username || typeof username !== 'string') {
|
|
128
|
+
throw badRequest('Username is required');
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const count = await importFromUsername(ctx.db, username.trim(), erase === true);
|
|
132
|
+
return { success: true, count };
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
throw failed(err instanceof Error ? err.message : 'Import failed');
|
|
136
|
+
}
|
|
137
|
+
}),
|
|
138
|
+
malImport: protectedProcedure.input(trackerMalInput()).mutation(async ({ ctx, input }) => {
|
|
139
|
+
const { username, erase, useOfflineDb, skipFallback } = input;
|
|
140
|
+
if (!username || typeof username !== 'string') {
|
|
141
|
+
throw badRequest('MAL username is required');
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const count = await importFromMalUsername(ctx.db, username.trim(), {
|
|
145
|
+
erase: erase === true,
|
|
146
|
+
useOfflineDb: useOfflineDb !== false,
|
|
147
|
+
skipFallback: skipFallback === true,
|
|
148
|
+
});
|
|
149
|
+
return { success: true, count };
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
throw mapImportError(err instanceof Error ? err.message : 'Import failed');
|
|
153
|
+
}
|
|
154
|
+
}),
|
|
155
|
+
mangaSync: protectedProcedure.input(trackerMangaSyncInput()).mutation(async ({ ctx, input }) => {
|
|
156
|
+
const { direction } = input;
|
|
157
|
+
try {
|
|
158
|
+
const mangaDb = ctx.mangaDb;
|
|
159
|
+
if (!mangaDb || mangaDb.isClosedCheck()) {
|
|
160
|
+
throw unavailable('Manga database is not ready');
|
|
161
|
+
}
|
|
162
|
+
const syncDirection = direction === 'pull-only' ? 'pull-only' : 'two-way';
|
|
163
|
+
const result = await syncAniListManga(ctx.db, mangaDb, {
|
|
164
|
+
direction: syncDirection,
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
success: true,
|
|
168
|
+
summary: result.summary,
|
|
169
|
+
details: result.details,
|
|
170
|
+
direction: syncDirection,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
if (err instanceof TRPCError)
|
|
175
|
+
throw err;
|
|
176
|
+
throw failed(err instanceof Error ? err.message : 'Manga sync failed');
|
|
177
|
+
}
|
|
178
|
+
}),
|
|
179
|
+
mangaImport: protectedProcedure.input(trackerUsernameInput()).mutation(async ({ ctx, input }) => {
|
|
180
|
+
const { username, erase } = input;
|
|
181
|
+
if (!username || typeof username !== 'string') {
|
|
182
|
+
throw badRequest('Username is required');
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const mangaDb = ctx.mangaDb;
|
|
186
|
+
if (!mangaDb || mangaDb.isClosedCheck()) {
|
|
187
|
+
throw unavailable('Manga database is not ready');
|
|
188
|
+
}
|
|
189
|
+
const count = await importFromUsernameManga(ctx.db, mangaDb, username.trim(), erase === true);
|
|
190
|
+
return { success: true, count };
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
if (err instanceof TRPCError)
|
|
194
|
+
throw err;
|
|
195
|
+
throw failed(err instanceof Error ? err.message : 'Import failed');
|
|
196
|
+
}
|
|
197
|
+
}),
|
|
198
|
+
mangaMalImport: protectedProcedure
|
|
199
|
+
.input(trackerUsernameInput())
|
|
200
|
+
.mutation(async ({ ctx, input }) => {
|
|
201
|
+
const { username, erase } = input;
|
|
202
|
+
if (!username || typeof username !== 'string') {
|
|
203
|
+
throw badRequest('Username is required');
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const mangaDb = ctx.mangaDb;
|
|
207
|
+
if (!mangaDb || mangaDb.isClosedCheck()) {
|
|
208
|
+
throw unavailable('Manga database is not ready');
|
|
209
|
+
}
|
|
210
|
+
const result = await importMangaFromMalUsername(ctx.db, mangaDb, username.trim(), erase === true);
|
|
211
|
+
return { success: true, ...result };
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
if (err instanceof TRPCError)
|
|
215
|
+
throw err;
|
|
216
|
+
throw mapImportError(err instanceof Error ? err.message : 'Import failed');
|
|
217
|
+
}
|
|
218
|
+
}),
|
|
219
|
+
});
|