@serifpersia/dango 3.3.4
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/LICENSE +21 -0
- package/README.md +258 -0
- package/client/dist/assets/AnimeCard-B5A20jAI.js +1 -0
- package/client/dist/assets/AnimeInfo-0huPAq0O.js +1 -0
- package/client/dist/assets/AnimeInfo-BFWtHlPC.css +1 -0
- package/client/dist/assets/AnimeInfoPage-Dqr5QH6R.js +2 -0
- package/client/dist/assets/AnimeMetaDetails-BPvv5xIl.js +1 -0
- package/client/dist/assets/AnimeMetaDetails-BwNQnRCd.css +1 -0
- package/client/dist/assets/Asmr-B6ZiqkmU.js +2 -0
- package/client/dist/assets/Asmr-Cq2ua6Zk.css +1 -0
- package/client/dist/assets/Asmr.module-ChUXDZKx.js +1 -0
- package/client/dist/assets/AsmrResetProgressModal-Cqsoo3XC.js +1 -0
- package/client/dist/assets/AvSyncCalibrator-C1_iBSyL.css +1 -0
- package/client/dist/assets/AvSyncCalibrator-C8ZoIwMY.js +10 -0
- package/client/dist/assets/ErrorMessage-CXoRbrLN.css +1 -0
- package/client/dist/assets/ErrorMessage-z4f4WGgP.js +1 -0
- package/client/dist/assets/Home-DtRtTuI9.css +1 -0
- package/client/dist/assets/Home-thJhPfs1.js +1 -0
- package/client/dist/assets/Insights-DKtoVe6L.css +1 -0
- package/client/dist/assets/Insights-ZlgPrzGD.js +1 -0
- package/client/dist/assets/LibraryListPage-CCiee-Ot.js +1 -0
- package/client/dist/assets/ListeningList-ctAz_6Zi.js +1 -0
- package/client/dist/assets/Manga-BI9WMTx5.js +1 -0
- package/client/dist/assets/Manga-Dp_-l1Pw.css +1 -0
- package/client/dist/assets/Manga.module-BcfOdsNP.js +1 -0
- package/client/dist/assets/MangaInfoPage-DcrEr7Sk.js +1 -0
- package/client/dist/assets/MangaReadPage-SYg63m48.js +1 -0
- package/client/dist/assets/MangaResetProgressModal-DX-1PS4h.js +1 -0
- package/client/dist/assets/Map-CXFqXmsZ.css +1 -0
- package/client/dist/assets/Map-Dj51g5zL.js +1 -0
- package/client/dist/assets/Mature-DJmUUtlF.css +1 -0
- package/client/dist/assets/Mature-Dqh_9SA3.js +1 -0
- package/client/dist/assets/MediaCard-Be1ST9oj.js +1 -0
- package/client/dist/assets/MediaCard-DB6Yjgky.css +1 -0
- package/client/dist/assets/Music-BGH_0voi.css +1 -0
- package/client/dist/assets/Music-zsPwT8_5.js +1 -0
- package/client/dist/assets/OptionListMenu-Drc_vybW.js +1 -0
- package/client/dist/assets/Player-B036Ic3m.css +1 -0
- package/client/dist/assets/Player-BEtAYps2.js +56 -0
- package/client/dist/assets/PlayerRedirect-D70t8VUW.js +1 -0
- package/client/dist/assets/PlayerSettings-Bj6SsmQ2.css +1 -0
- package/client/dist/assets/PlayerSettings-Cwoji-P7.js +1 -0
- package/client/dist/assets/QueueOptionsButton-B6BMq66e.js +1 -0
- package/client/dist/assets/QueueOptionsButton-CM15uQn8.css +1 -0
- package/client/dist/assets/QueueRail-BUYEjdaq.css +1 -0
- package/client/dist/assets/QueueRail-VxpUDBSm.js +1 -0
- package/client/dist/assets/Radio-B2Q_Egj7.js +1 -0
- package/client/dist/assets/Radio-CCHNFN7R.css +1 -0
- package/client/dist/assets/Radio.module-DGSZLpKM.js +1 -0
- package/client/dist/assets/ReadingList-O0QPwXu7.js +1 -0
- package/client/dist/assets/ResetProgressModal-CR_ovLAu.js +1 -0
- package/client/dist/assets/Search-C8e2nlDD.js +1 -0
- package/client/dist/assets/Search-Cs45oAwI.css +1 -0
- package/client/dist/assets/Search.module-CMEai-B-.js +1 -0
- package/client/dist/assets/Settings-AuFxQcJF.css +1 -0
- package/client/dist/assets/Settings-BgPWBl2V.js +21 -0
- package/client/dist/assets/SkeletonGrid-CtHKKMLj.js +1 -0
- package/client/dist/assets/SkeletonGrid-DhPYefXr.css +1 -0
- package/client/dist/assets/SynopsisText-BcpwT5iw.js +1 -0
- package/client/dist/assets/SynopsisText-CYv2W3yR.css +1 -0
- package/client/dist/assets/Trackers-Bfoiyuzy.js +2 -0
- package/client/dist/assets/Trackers-D5XBOCDp.css +1 -0
- package/client/dist/assets/Tv-Ca0fCdko.js +3 -0
- package/client/dist/assets/Tv-CuEl3Iol.css +1 -0
- package/client/dist/assets/TvInfo-Bu36xJyO.css +1 -0
- package/client/dist/assets/TvInfo-D_scgw_h.js +1 -0
- package/client/dist/assets/TvPopup-YTI7dEbJ.js +1 -0
- package/client/dist/assets/TvSearch-CxU33UOf.js +1 -0
- package/client/dist/assets/TvWatchlist-B4X1fWEH.js +1 -0
- package/client/dist/assets/VirtualKeyboard-6ZNTym4R.js +1 -0
- package/client/dist/assets/VirtualKeyboard-KIPc5ZGH.css +1 -0
- package/client/dist/assets/Watchlist-BiDEU0iV.css +1 -0
- package/client/dist/assets/Watchlist-P6YMZUdF.js +1 -0
- package/client/dist/assets/Watchlist.module-XGtVQr1d.js +1 -0
- package/client/dist/assets/hls-BOLXTnjL.js +42 -0
- package/client/dist/assets/hls-C4cG9wQw.js +1 -0
- package/client/dist/assets/index-BOKlZEHq.js +30354 -0
- package/client/dist/assets/index-CUm1meqr.css +1 -0
- package/client/dist/assets/index-IZgh1beL.js +178 -0
- package/client/dist/assets/useAnimeInfoData-DeuwDSnI.js +1 -0
- package/client/dist/assets/useAsmr-CbMVRjmV.js +1 -0
- package/client/dist/assets/useAsmrPopup-D91Q3CBg.js +1 -0
- package/client/dist/assets/useIsMobile-Dm_b6Ob3.js +1 -0
- package/client/dist/assets/useMangaLibrary-BPvFb1lU.js +1 -0
- package/client/dist/assets/useMangaPopup-BOL2mQJs.js +1 -0
- package/client/dist/assets/useMatureConsent-CT7-KfzG.js +1 -0
- package/client/dist/assets/useProviders-BsRGONMA.js +1 -0
- package/client/dist/assets/useTv-D37MfTHA.js +1 -0
- package/client/dist/assets/useTvLibrary-Cwxgy_Bg.js +1 -0
- package/client/dist/assets/vendor-core-8R2fFbyN.js +1 -0
- package/client/dist/assets/vendor-icons-CDMuNDeL.js +1 -0
- package/client/dist/assets/vendor-query-BXRECSI6.js +1 -0
- package/client/dist/assets/vendor-ui-CUDCvRS_.js +1 -0
- package/client/dist/av-sync-test.mp4 +0 -0
- package/client/dist/favicon.ico +0 -0
- package/client/dist/index.html +46 -0
- package/client/dist/logo-mochi.png +0 -0
- package/client/dist/logo-sakura.png +0 -0
- package/client/dist/logo.png +0 -0
- package/client/dist/placeholder.svg +4 -0
- package/client/dist/robots.txt +3 -0
- package/orchestrator.js +534 -0
- package/package.json +85 -0
- package/server/dist/app-auth.js +177 -0
- package/server/dist/config.js +175 -0
- package/server/dist/controllers/asmr-library.controller.js +288 -0
- package/server/dist/controllers/auth.controller.js +225 -0
- package/server/dist/controllers/data.controller.js +766 -0
- package/server/dist/controllers/insights.controller.js +198 -0
- package/server/dist/controllers/manga-library.controller.js +296 -0
- package/server/dist/controllers/music.controller.js +698 -0
- package/server/dist/controllers/proxy.controller.js +820 -0
- package/server/dist/controllers/settings.controller.js +609 -0
- package/server/dist/controllers/tv-library.controller.js +310 -0
- package/server/dist/controllers/watchlist.controller.js +1015 -0
- package/server/dist/db.js +145 -0
- package/server/dist/discord-gateway.js +391 -0
- package/server/dist/discord-rpc.js +427 -0
- package/server/dist/github-sync.js +512 -0
- package/server/dist/google.js +748 -0
- package/server/dist/lib/anilist.js +1549 -0
- package/server/dist/lib/anischedule.js +194 -0
- package/server/dist/lib/discord-roles-sync.service.js +130 -0
- package/server/dist/lib/ipc.js +24 -0
- package/server/dist/lib/kitsu.js +544 -0
- package/server/dist/lib/mal.js +934 -0
- package/server/dist/lib/migration.js +329 -0
- package/server/dist/lib/offline-db.js +401 -0
- package/server/dist/lib/temp-ids.js +3 -0
- package/server/dist/lib/tmdb.js +72 -0
- package/server/dist/lib/tracker/anilist-tracker.js +245 -0
- package/server/dist/lib/tracker/sync.service.js +496 -0
- package/server/dist/lib/ytmusic.js +125 -0
- package/server/dist/logger.js +16 -0
- package/server/dist/providers/manga/manga.types.js +7 -0
- package/server/dist/providers/provider.interface.js +1 -0
- package/server/dist/providers/remote-loader.js +574 -0
- package/server/dist/providers/remote-types.js +117 -0
- package/server/dist/providers/title-matching.js +225 -0
- package/server/dist/providers/tv.types.js +1 -0
- package/server/dist/rclone.js +120 -0
- package/server/dist/repositories/asmr.repository.js +123 -0
- package/server/dist/repositories/insights.repository.js +85 -0
- package/server/dist/repositories/library.repository.js +108 -0
- package/server/dist/repositories/mal-cache.repository.js +55 -0
- package/server/dist/repositories/manga.repository.js +138 -0
- package/server/dist/repositories/notifications.repository.js +41 -0
- package/server/dist/repositories/queue.repository.js +61 -0
- package/server/dist/repositories/settings.repository.js +12 -0
- package/server/dist/repositories/shows-meta.repository.js +42 -0
- package/server/dist/repositories/temp-show-ids.repository.js +51 -0
- package/server/dist/repositories/tv.repository.js +151 -0
- package/server/dist/repositories/watched-episodes.repository.js +52 -0
- package/server/dist/repositories/watchlist.repository.js +71 -0
- package/server/dist/request-logger.js +16 -0
- package/server/dist/routes/asmr-library.routes.js +23 -0
- package/server/dist/routes/asmr.routes.js +81 -0
- package/server/dist/routes/auth.routes.js +25 -0
- package/server/dist/routes/data.routes.js +47 -0
- package/server/dist/routes/discord-gateway.routes.js +51 -0
- package/server/dist/routes/insights.routes.js +34 -0
- package/server/dist/routes/lan-auth.routes.js +46 -0
- package/server/dist/routes/manga-library.routes.js +23 -0
- package/server/dist/routes/manga.routes.js +141 -0
- package/server/dist/routes/music.routes.js +20 -0
- package/server/dist/routes/providers.routes.js +17 -0
- package/server/dist/routes/proxy.routes.js +12 -0
- package/server/dist/routes/radio.routes.js +138 -0
- package/server/dist/routes/settings.routes.js +24 -0
- package/server/dist/routes/tracker.routes.js +164 -0
- package/server/dist/routes/translate.routes.js +88 -0
- package/server/dist/routes/tv-library.routes.js +23 -0
- package/server/dist/routes/tv.routes.js +629 -0
- package/server/dist/routes/watchlist.routes.js +341 -0
- package/server/dist/scripts/backfill-episode-duration.js +42 -0
- package/server/dist/server.js +529 -0
- package/server/dist/shipped-defaults.js +7 -0
- package/server/dist/sync-config.js +23 -0
- package/server/dist/sync-payload.js +91 -0
- package/server/dist/sync.js +1128 -0
- package/server/dist/utils/cache.utils.js +25 -0
- package/server/dist/utils/cookie.utils.js +13 -0
- package/server/dist/utils/db-utils.js +9 -0
- package/server/dist/utils/env.utils.js +77 -0
- package/server/dist/utils/http.utils.js +70 -0
- package/server/dist/utils/machine-id.js +40 -0
- package/server/dist/utils/megaplay.utils.js +41 -0
- package/server/dist/utils/request-context.js +2 -0
- package/server/dist/utils/security.utils.js +94 -0
- package/server/package.json +44 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
import { AniListTracker } from './anilist-tracker.js';
|
|
2
|
+
import { WatchlistRepository } from '../../repositories/watchlist.repository.js';
|
|
3
|
+
import { WatchedEpisodesRepository } from '../../repositories/watched-episodes.repository.js';
|
|
4
|
+
import { ShowsMetaRepository } from '../../repositories/shows-meta.repository.js';
|
|
5
|
+
import { SettingsRepository } from '../../repositories/settings.repository.js';
|
|
6
|
+
import { NotificationsRepository } from '../../repositories/notifications.repository.js';
|
|
7
|
+
import { performWriteTransaction } from '../../sync.js';
|
|
8
|
+
import { dbGet } from '../../utils/db-utils.js';
|
|
9
|
+
import logger from '../../logger.js';
|
|
10
|
+
import { fetchMalUserList } from '../mal.js';
|
|
11
|
+
import { offlineDb } from '../offline-db.js';
|
|
12
|
+
import { getShowMetaById, getShowMetaByMalId, isAnilistRateLimited, searchAnilistByTitle, } from '../anilist.js';
|
|
13
|
+
import { kitsuSearchAnime } from '../kitsu.js';
|
|
14
|
+
const TOKEN_KEY = 'tracker_anilist_token';
|
|
15
|
+
const USER_KEY = 'tracker_anilist_user';
|
|
16
|
+
const SYNC_STATE_KEY = 'tracker_anilist_sync_state';
|
|
17
|
+
async function readSyncState(db) {
|
|
18
|
+
const row = await SettingsRepository.getByKey(db, SYNC_STATE_KEY);
|
|
19
|
+
if (!row?.value)
|
|
20
|
+
return {};
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(row.value);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function getWatchedCount(db, showId) {
|
|
29
|
+
const row = await dbGet(db, 'SELECT COUNT(DISTINCT episodeNumber) as total FROM watched_episodes WHERE showId = ?', [showId]);
|
|
30
|
+
return row?.total ?? 0;
|
|
31
|
+
}
|
|
32
|
+
function resolveStatus(localStatus, remoteStatus, remoteUpdated, localLastSync) {
|
|
33
|
+
if (remoteUpdated > localLastSync && remoteStatus !== localStatus) {
|
|
34
|
+
return { status: remoteStatus, pull: true };
|
|
35
|
+
}
|
|
36
|
+
if (remoteStatus !== localStatus) {
|
|
37
|
+
return { status: localStatus, pull: false };
|
|
38
|
+
}
|
|
39
|
+
return { status: localStatus, pull: false };
|
|
40
|
+
}
|
|
41
|
+
export async function syncAniList(db) {
|
|
42
|
+
const tokenRow = await SettingsRepository.getByKey(db, TOKEN_KEY);
|
|
43
|
+
const token = tokenRow?.value;
|
|
44
|
+
if (!token)
|
|
45
|
+
throw new Error('AniList is not connected. Please log in first.');
|
|
46
|
+
const tracker = new AniListTracker(token);
|
|
47
|
+
const viewer = await tracker.getViewer();
|
|
48
|
+
const remoteEntries = await tracker.fetchUserAnimeList(viewer.id);
|
|
49
|
+
const localItems = await WatchlistRepository.getAll(db);
|
|
50
|
+
const syncState = await readSyncState(db);
|
|
51
|
+
const remoteByMediaId = new Map();
|
|
52
|
+
for (const entry of remoteEntries)
|
|
53
|
+
remoteByMediaId.set(entry.mediaId, entry);
|
|
54
|
+
const localByMediaId = new Map();
|
|
55
|
+
for (const item of localItems) {
|
|
56
|
+
const meta = (await ShowsMetaRepository.getById(db, item.id));
|
|
57
|
+
const anilistId = meta?.anilistId;
|
|
58
|
+
if (anilistId == null || !Number.isFinite(anilistId) || String(anilistId) !== String(item.id)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!localByMediaId.has(anilistId)) {
|
|
62
|
+
localByMediaId.set(anilistId, item);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const allMediaIds = new Set([
|
|
66
|
+
...localByMediaId.keys(),
|
|
67
|
+
...remoteByMediaId.keys(),
|
|
68
|
+
...Object.keys(syncState)
|
|
69
|
+
.map((k) => Number.parseInt(k, 10))
|
|
70
|
+
.filter((n) => Number.isFinite(n)),
|
|
71
|
+
]);
|
|
72
|
+
const summary = { pushed: 0, pulled: 0, merged: 0, unchanged: 0, errors: [] };
|
|
73
|
+
const watchlistUpserts = [];
|
|
74
|
+
const metaUpserts = [];
|
|
75
|
+
const watchedInserts = [];
|
|
76
|
+
const stateUpdates = {};
|
|
77
|
+
const watchlistDeletes = [];
|
|
78
|
+
const pushUpdates = [];
|
|
79
|
+
const remoteDeleteEntryIds = [];
|
|
80
|
+
const now = Math.floor(Date.now() / 1000);
|
|
81
|
+
for (const mediaId of allMediaIds) {
|
|
82
|
+
const local = localByMediaId.get(mediaId);
|
|
83
|
+
const remote = remoteByMediaId.get(mediaId);
|
|
84
|
+
const showId = String(mediaId);
|
|
85
|
+
try {
|
|
86
|
+
if (local && !remote) {
|
|
87
|
+
if (syncState[showId] && remoteEntries.length > 0) {
|
|
88
|
+
watchlistDeletes.push(showId);
|
|
89
|
+
stateUpdates[showId] = undefined;
|
|
90
|
+
summary.pulled++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const watchedCount = await getWatchedCount(db, showId);
|
|
94
|
+
pushUpdates.push({
|
|
95
|
+
mediaId,
|
|
96
|
+
status: DANGO_STATUS_OR_FALLBACK(local.status),
|
|
97
|
+
progress: watchedCount,
|
|
98
|
+
});
|
|
99
|
+
stateUpdates[showId] = { lastSyncedAt: now, remoteUpdatedAt: now };
|
|
100
|
+
summary.pushed++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!local && remote) {
|
|
104
|
+
if (syncState[showId]) {
|
|
105
|
+
if (remote.entryId) {
|
|
106
|
+
remoteDeleteEntryIds.push({ showId, entryId: remote.entryId });
|
|
107
|
+
stateUpdates[showId] = undefined;
|
|
108
|
+
summary.pushed++;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
stateUpdates[showId] = undefined;
|
|
112
|
+
summary.pushed++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
logger.debug({ mediaId, remoteStatus: remote.status }, '[AniList Sync] Remote only -> pulling to local');
|
|
116
|
+
const title = remote.title.english || remote.title.romaji || `Anime #${mediaId}`;
|
|
117
|
+
watchlistUpserts.push({
|
|
118
|
+
id: showId,
|
|
119
|
+
name: title,
|
|
120
|
+
thumbnail: remote.coverImage ?? '',
|
|
121
|
+
status: remote.status,
|
|
122
|
+
nativeName: remote.title.native ?? '',
|
|
123
|
+
englishName: remote.title.english ?? '',
|
|
124
|
+
type: 'TV',
|
|
125
|
+
});
|
|
126
|
+
metaUpserts.push({
|
|
127
|
+
id: showId,
|
|
128
|
+
name: title,
|
|
129
|
+
thumbnail: remote.coverImage,
|
|
130
|
+
nativeName: remote.title.native,
|
|
131
|
+
englishName: remote.title.english,
|
|
132
|
+
episodeCount: remote.totalEpisodes,
|
|
133
|
+
anilistId: mediaId,
|
|
134
|
+
episodeDuration: remote.episodeDuration,
|
|
135
|
+
});
|
|
136
|
+
if (remote.progress > 0) {
|
|
137
|
+
watchedInserts.push({ showId, from: 1, to: remote.progress, updatedAt: remote.updatedAt });
|
|
138
|
+
}
|
|
139
|
+
stateUpdates[showId] = { lastSyncedAt: now, remoteUpdatedAt: remote.updatedAt };
|
|
140
|
+
summary.pulled++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!local && !remote && syncState[showId]) {
|
|
144
|
+
stateUpdates[showId] = undefined;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (local && remote) {
|
|
148
|
+
const watchedCount = await getWatchedCount(db, showId);
|
|
149
|
+
const localLastSync = syncState[showId]?.lastSyncedAt ?? 0;
|
|
150
|
+
const remoteUpdated = remote.updatedAt || 0;
|
|
151
|
+
const targetProgress = Math.max(watchedCount, remote.progress);
|
|
152
|
+
const shouldPushProgress = watchedCount > remote.progress;
|
|
153
|
+
const shouldPullProgress = remote.progress > watchedCount;
|
|
154
|
+
const { status: targetStatus, pull: pullStatus } = resolveStatus(local.status, remote.status, remoteUpdated, localLastSync);
|
|
155
|
+
const statusDiffers = remote.status !== local.status;
|
|
156
|
+
if (shouldPushProgress || (!pullStatus && statusDiffers)) {
|
|
157
|
+
pushUpdates.push({
|
|
158
|
+
mediaId,
|
|
159
|
+
status: targetStatus,
|
|
160
|
+
progress: targetProgress,
|
|
161
|
+
});
|
|
162
|
+
if (targetStatus !== local.status) {
|
|
163
|
+
watchlistUpserts.push({
|
|
164
|
+
id: showId,
|
|
165
|
+
name: local.name,
|
|
166
|
+
thumbnail: local.thumbnail ?? '',
|
|
167
|
+
status: targetStatus,
|
|
168
|
+
nativeName: local.nativeName ?? '',
|
|
169
|
+
englishName: local.englishName ?? '',
|
|
170
|
+
type: local.type ?? '',
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (shouldPullProgress) {
|
|
174
|
+
watchedInserts.push({
|
|
175
|
+
showId,
|
|
176
|
+
from: watchedCount + 1,
|
|
177
|
+
to: targetProgress,
|
|
178
|
+
updatedAt: remoteUpdated,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
stateUpdates[showId] = { lastSyncedAt: now, remoteUpdatedAt: now };
|
|
182
|
+
summary.merged++;
|
|
183
|
+
}
|
|
184
|
+
else if (pullStatus || shouldPullProgress) {
|
|
185
|
+
if (targetStatus !== local.status) {
|
|
186
|
+
watchlistUpserts.push({
|
|
187
|
+
id: showId,
|
|
188
|
+
name: local.name,
|
|
189
|
+
thumbnail: local.thumbnail ?? '',
|
|
190
|
+
status: targetStatus,
|
|
191
|
+
nativeName: local.nativeName ?? '',
|
|
192
|
+
englishName: local.englishName ?? '',
|
|
193
|
+
type: local.type ?? '',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (shouldPullProgress) {
|
|
197
|
+
watchedInserts.push({
|
|
198
|
+
showId,
|
|
199
|
+
from: watchedCount + 1,
|
|
200
|
+
to: targetProgress,
|
|
201
|
+
updatedAt: remoteUpdated,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
stateUpdates[showId] = { lastSyncedAt: now, remoteUpdatedAt: remoteUpdated };
|
|
205
|
+
summary.merged++;
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
summary.unchanged++;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
214
|
+
logger.warn({ err, mediaId }, '[AniList Sync] Entry failed');
|
|
215
|
+
summary.errors.push(`${local?.name ?? `Media ${mediaId}`}: ${message}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (pushUpdates.length > 0 || remoteDeleteEntryIds.length > 0 || watchlistDeletes.length > 0) {
|
|
219
|
+
const deleteCap = Math.max(5, Math.ceil(localItems.length * 0.1));
|
|
220
|
+
const totalDeletes = watchlistDeletes.length + remoteDeleteEntryIds.length;
|
|
221
|
+
if (totalDeletes > deleteCap) {
|
|
222
|
+
throw new Error(`AniList sync would delete ${totalDeletes} shows but only ${remoteEntries.length} were fetched. Aborting to protect your library — if you really removed that many shows on AniList, delete them locally first and sync again.`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (pushUpdates.length > 0) {
|
|
226
|
+
const batchSize = 10;
|
|
227
|
+
for (let i = 0; i < pushUpdates.length; i += batchSize) {
|
|
228
|
+
const batch = pushUpdates.slice(i, i + batchSize);
|
|
229
|
+
await tracker.batchUpdateMediaEntries(batch);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (remoteDeleteEntryIds.length > 0) {
|
|
233
|
+
const deleteBatch = remoteDeleteEntryIds.map((r) => r.entryId);
|
|
234
|
+
const batchSize = 10;
|
|
235
|
+
for (let i = 0; i < deleteBatch.length; i += batchSize) {
|
|
236
|
+
const batch = deleteBatch.slice(i, i + batchSize);
|
|
237
|
+
await tracker.batchDeleteMediaEntries(batch);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (watchlistUpserts.length > 0 ||
|
|
241
|
+
metaUpserts.length > 0 ||
|
|
242
|
+
watchedInserts.length > 0 ||
|
|
243
|
+
watchlistDeletes.length > 0 ||
|
|
244
|
+
Object.keys(stateUpdates).length > 0) {
|
|
245
|
+
await performWriteTransaction(db, (tx) => {
|
|
246
|
+
for (const item of watchlistUpserts) {
|
|
247
|
+
WatchlistRepository.upsert(tx, item);
|
|
248
|
+
}
|
|
249
|
+
for (const meta of metaUpserts) {
|
|
250
|
+
ShowsMetaRepository.upsert(tx, meta);
|
|
251
|
+
}
|
|
252
|
+
for (const w of watchedInserts) {
|
|
253
|
+
for (let ep = w.from; ep <= w.to; ep++) {
|
|
254
|
+
WatchedEpisodesRepository.insertIfMissing(tx, {
|
|
255
|
+
showId: w.showId,
|
|
256
|
+
episodeNumber: String(ep),
|
|
257
|
+
watchedAt: toSqliteDatetime(w.updatedAt),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
for (const id of watchlistDeletes) {
|
|
262
|
+
WatchlistRepository.delete(tx, id);
|
|
263
|
+
WatchedEpisodesRepository.deleteByShow(tx, id);
|
|
264
|
+
NotificationsRepository.deleteByShow(tx, id);
|
|
265
|
+
}
|
|
266
|
+
const mergedState = { ...syncState };
|
|
267
|
+
for (const [k, v] of Object.entries(stateUpdates)) {
|
|
268
|
+
if (v === undefined)
|
|
269
|
+
delete mergedState[k];
|
|
270
|
+
else
|
|
271
|
+
mergedState[k] = v;
|
|
272
|
+
}
|
|
273
|
+
SettingsRepository.upsert(tx, SYNC_STATE_KEY, JSON.stringify(mergedState));
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return summary;
|
|
277
|
+
}
|
|
278
|
+
function toSqliteDatetime(unixSeconds) {
|
|
279
|
+
if (!unixSeconds || unixSeconds <= 0)
|
|
280
|
+
return undefined;
|
|
281
|
+
return new Date(unixSeconds * 1000).toISOString().slice(0, 19).replace('T', ' ');
|
|
282
|
+
}
|
|
283
|
+
function DANGO_STATUS_OR_FALLBACK(status) {
|
|
284
|
+
const known = ['Watching', 'Completed', 'On-Hold', 'Dropped', 'Planned'];
|
|
285
|
+
return known.includes(status) ? status : undefined;
|
|
286
|
+
}
|
|
287
|
+
export async function importFromUsername(db, username, erase = false) {
|
|
288
|
+
const tokenRow = await SettingsRepository.getByKey(db, TOKEN_KEY);
|
|
289
|
+
const token = tokenRow?.value;
|
|
290
|
+
const tracker = new AniListTracker(token);
|
|
291
|
+
let entries;
|
|
292
|
+
if (token) {
|
|
293
|
+
const viewer = await tracker.getViewer();
|
|
294
|
+
entries = await tracker.fetchUserAnimeList(viewer.id);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
entries = await tracker.fetchUserAnimeList(username);
|
|
298
|
+
}
|
|
299
|
+
if (entries.length === 0)
|
|
300
|
+
return 0;
|
|
301
|
+
const now = Math.floor(Date.now() / 1000);
|
|
302
|
+
const syncState = await readSyncState(db);
|
|
303
|
+
await performWriteTransaction(db, (tx) => {
|
|
304
|
+
if (erase)
|
|
305
|
+
SettingsRepository.clearWatchlist(tx);
|
|
306
|
+
for (const remote of entries) {
|
|
307
|
+
const showId = String(remote.mediaId);
|
|
308
|
+
const title = remote.title.english || remote.title.romaji || `Anime #${remote.mediaId}`;
|
|
309
|
+
WatchlistRepository.upsert(tx, {
|
|
310
|
+
id: showId,
|
|
311
|
+
name: title,
|
|
312
|
+
thumbnail: remote.coverImage ?? '',
|
|
313
|
+
status: remote.status,
|
|
314
|
+
nativeName: remote.title.native ?? '',
|
|
315
|
+
englishName: remote.title.english ?? '',
|
|
316
|
+
type: 'TV',
|
|
317
|
+
});
|
|
318
|
+
ShowsMetaRepository.upsert(tx, {
|
|
319
|
+
id: showId,
|
|
320
|
+
name: title,
|
|
321
|
+
thumbnail: remote.coverImage,
|
|
322
|
+
nativeName: remote.title.native,
|
|
323
|
+
englishName: remote.title.english,
|
|
324
|
+
episodeCount: remote.totalEpisodes,
|
|
325
|
+
anilistId: remote.mediaId,
|
|
326
|
+
});
|
|
327
|
+
if (remote.progress > 0) {
|
|
328
|
+
for (let ep = 1; ep <= remote.progress; ep++) {
|
|
329
|
+
WatchedEpisodesRepository.insertIfMissing(tx, {
|
|
330
|
+
showId,
|
|
331
|
+
episodeNumber: String(ep),
|
|
332
|
+
watchedAt: toSqliteDatetime(remote.updatedAt),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
syncState[showId] = { lastSyncedAt: now, remoteUpdatedAt: remote.updatedAt };
|
|
337
|
+
}
|
|
338
|
+
SettingsRepository.upsert(tx, SYNC_STATE_KEY, JSON.stringify(syncState));
|
|
339
|
+
});
|
|
340
|
+
return entries.length;
|
|
341
|
+
}
|
|
342
|
+
function mapMalListStatus(status) {
|
|
343
|
+
switch (status) {
|
|
344
|
+
case 1:
|
|
345
|
+
return 'Watching';
|
|
346
|
+
case 2:
|
|
347
|
+
return 'Completed';
|
|
348
|
+
case 3:
|
|
349
|
+
return 'On-Hold';
|
|
350
|
+
case 4:
|
|
351
|
+
return 'Dropped';
|
|
352
|
+
case 6:
|
|
353
|
+
return 'Planned';
|
|
354
|
+
default:
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
async function searchTitleForMalImport(title) {
|
|
359
|
+
const result = await searchAnilistByTitle(title);
|
|
360
|
+
if (result)
|
|
361
|
+
return result.id;
|
|
362
|
+
if (isAnilistRateLimited()) {
|
|
363
|
+
try {
|
|
364
|
+
const fb = await kitsuSearchAnime({ query: title, page: 1, perPage: 5 });
|
|
365
|
+
if (fb.length > 0)
|
|
366
|
+
return fb[0].id > 0 ? fb[0].id : null;
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
// ignore
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
export async function importFromMalUsername(db, username, options = {}) {
|
|
375
|
+
const { erase = false, useOfflineDb = true, skipFallback = false } = options;
|
|
376
|
+
const entries = await fetchMalUserList(username);
|
|
377
|
+
if (entries.length === 0)
|
|
378
|
+
return 0;
|
|
379
|
+
const shows = [];
|
|
380
|
+
const metas = [];
|
|
381
|
+
const watched = [];
|
|
382
|
+
const fallbackQueue = [];
|
|
383
|
+
for (const entry of entries) {
|
|
384
|
+
const status = mapMalListStatus(entry.status);
|
|
385
|
+
if (!status)
|
|
386
|
+
continue;
|
|
387
|
+
const offline = useOfflineDb ? offlineDb.getByMalId(entry.malId) : null;
|
|
388
|
+
if (offline) {
|
|
389
|
+
const showId = String(offline.anilistId);
|
|
390
|
+
const title = offline.title || entry.titleEnglish || entry.title || `MAL #${entry.malId}`;
|
|
391
|
+
shows.push({
|
|
392
|
+
id: showId,
|
|
393
|
+
name: title,
|
|
394
|
+
thumbnail: offline.thumbnail ?? entry.imageUrl ?? undefined,
|
|
395
|
+
status,
|
|
396
|
+
englishName: entry.titleEnglish ?? undefined,
|
|
397
|
+
type: offline.type ?? entry.type ?? undefined,
|
|
398
|
+
});
|
|
399
|
+
if (offline.thumbnail || offline.type || offline.genres) {
|
|
400
|
+
metas.push({
|
|
401
|
+
id: showId,
|
|
402
|
+
thumbnail: offline.thumbnail,
|
|
403
|
+
type: offline.type,
|
|
404
|
+
genres: offline.genres,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
if (entry.watchedEpisodes > 0)
|
|
408
|
+
watched.push({ showId, progress: entry.watchedEpisodes });
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
fallbackQueue.push(entry);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (fallbackQueue.length > 0 && !skipFallback) {
|
|
415
|
+
const BATCH_SIZE = 5;
|
|
416
|
+
for (let i = 0; i < fallbackQueue.length; i += BATCH_SIZE) {
|
|
417
|
+
const batch = fallbackQueue.slice(i, i + BATCH_SIZE);
|
|
418
|
+
const results = await Promise.allSettled(batch.map(async (entry) => {
|
|
419
|
+
const byMal = await getShowMetaByMalId(entry.malId).catch(() => null);
|
|
420
|
+
if (byMal?.anilistId)
|
|
421
|
+
return byMal;
|
|
422
|
+
const title = entry.titleEnglish || entry.title;
|
|
423
|
+
if (!title)
|
|
424
|
+
return null;
|
|
425
|
+
const id = await searchTitleForMalImport(title);
|
|
426
|
+
if (!id)
|
|
427
|
+
return null;
|
|
428
|
+
return getShowMetaById(String(id)).catch(() => null);
|
|
429
|
+
}));
|
|
430
|
+
results.forEach((r, idx) => {
|
|
431
|
+
if (r.status !== 'fulfilled' || !r.value)
|
|
432
|
+
return;
|
|
433
|
+
const meta = r.value;
|
|
434
|
+
const entry = batch[idx];
|
|
435
|
+
const status = mapMalListStatus(entry.status);
|
|
436
|
+
if (!status)
|
|
437
|
+
return;
|
|
438
|
+
const showId = meta.anilistId ? String(meta.anilistId) : meta._id;
|
|
439
|
+
const title = meta.names?.english ||
|
|
440
|
+
meta.names?.romaji ||
|
|
441
|
+
entry.titleEnglish ||
|
|
442
|
+
entry.title ||
|
|
443
|
+
`MAL #${entry.malId}`;
|
|
444
|
+
shows.push({
|
|
445
|
+
id: showId,
|
|
446
|
+
name: title,
|
|
447
|
+
thumbnail: meta.thumbnail || entry.imageUrl || undefined,
|
|
448
|
+
status,
|
|
449
|
+
englishName: entry.titleEnglish ?? undefined,
|
|
450
|
+
type: meta.type ?? entry.type ?? 'TV',
|
|
451
|
+
});
|
|
452
|
+
if (meta.thumbnail || meta.type) {
|
|
453
|
+
metas.push({ id: showId, thumbnail: meta.thumbnail, type: meta.type });
|
|
454
|
+
}
|
|
455
|
+
if (entry.watchedEpisodes > 0)
|
|
456
|
+
watched.push({ showId, progress: entry.watchedEpisodes });
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (shows.length > 0 || erase) {
|
|
461
|
+
await performWriteTransaction(db, (tx) => {
|
|
462
|
+
if (erase)
|
|
463
|
+
SettingsRepository.clearWatchlist(tx);
|
|
464
|
+
for (const show of shows) {
|
|
465
|
+
WatchlistRepository.upsert(tx, {
|
|
466
|
+
id: show.id,
|
|
467
|
+
name: show.name,
|
|
468
|
+
thumbnail: show.thumbnail ?? '',
|
|
469
|
+
status: show.status,
|
|
470
|
+
nativeName: '',
|
|
471
|
+
englishName: show.englishName ?? '',
|
|
472
|
+
type: show.type ?? 'TV',
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
for (const meta of metas) {
|
|
476
|
+
if (meta.thumbnail || meta.type || meta.genres) {
|
|
477
|
+
ShowsMetaRepository.upsert(tx, {
|
|
478
|
+
id: meta.id,
|
|
479
|
+
thumbnail: meta.thumbnail,
|
|
480
|
+
type: meta.type,
|
|
481
|
+
genres: meta.genres,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
for (const w of watched) {
|
|
486
|
+
for (let ep = 1; ep <= w.progress; ep++) {
|
|
487
|
+
WatchedEpisodesRepository.insertIfMissing(tx, {
|
|
488
|
+
showId: w.showId,
|
|
489
|
+
episodeNumber: String(ep),
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
return shows.length;
|
|
496
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { CONFIG } from '../config.js';
|
|
4
|
+
import logger from '../logger.js';
|
|
5
|
+
const COOKIE_PATH = path.join(CONFIG.ROOT, 'ytmusic_cookie.txt');
|
|
6
|
+
let publicTube = null;
|
|
7
|
+
let publicPromise = null;
|
|
8
|
+
let publicFailedAt = 0;
|
|
9
|
+
const PUBLIC_FAIL_COOLDOWN_MS = 60 * 1000;
|
|
10
|
+
let authedTube = null;
|
|
11
|
+
let ytModule = null;
|
|
12
|
+
let shimReady = false;
|
|
13
|
+
async function loadYoutube() {
|
|
14
|
+
if (!ytModule)
|
|
15
|
+
ytModule = await import('youtubei.js');
|
|
16
|
+
return ytModule;
|
|
17
|
+
}
|
|
18
|
+
async function ensureShim() {
|
|
19
|
+
if (shimReady)
|
|
20
|
+
return;
|
|
21
|
+
const { Platform, Log } = await loadYoutube();
|
|
22
|
+
Platform.shim.eval = (async (data) => new Function(data.output)());
|
|
23
|
+
Log.setLevel(Log.Level.ERROR);
|
|
24
|
+
shimReady = true;
|
|
25
|
+
}
|
|
26
|
+
function readCookie() {
|
|
27
|
+
try {
|
|
28
|
+
if (!fs.existsSync(COOKIE_PATH))
|
|
29
|
+
return null;
|
|
30
|
+
const raw = fs.readFileSync(COOKIE_PATH, 'utf-8').trim();
|
|
31
|
+
return raw || null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function getMusicCookie() {
|
|
38
|
+
return readCookie();
|
|
39
|
+
}
|
|
40
|
+
export function isParserVariantError(err) {
|
|
41
|
+
if (!err || typeof err !== 'object')
|
|
42
|
+
return false;
|
|
43
|
+
const name = err.constructor?.name;
|
|
44
|
+
if (name === 'ParsingError')
|
|
45
|
+
return true;
|
|
46
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
47
|
+
return /Expected node of any type|Cannot cast \S+ to one of|got ItemSection|is not valid JSON|Incorrect JSPB formatting/i.test(msg);
|
|
48
|
+
}
|
|
49
|
+
/** Public session: never signed in. Used for search/stream/home. */
|
|
50
|
+
export function getInnertube() {
|
|
51
|
+
if (publicTube)
|
|
52
|
+
return Promise.resolve(publicTube);
|
|
53
|
+
if (Date.now() - publicFailedAt < PUBLIC_FAIL_COOLDOWN_MS) {
|
|
54
|
+
return Promise.reject(new Error('YouTube session unavailable (cooling down)'));
|
|
55
|
+
}
|
|
56
|
+
if (!publicPromise) {
|
|
57
|
+
publicPromise = (async () => {
|
|
58
|
+
const { Innertube } = await loadYoutube();
|
|
59
|
+
await ensureShim();
|
|
60
|
+
try {
|
|
61
|
+
const yt = await Innertube.create({ generate_session_locally: false });
|
|
62
|
+
publicTube = yt;
|
|
63
|
+
return yt;
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
publicPromise = null;
|
|
67
|
+
publicFailedAt = Date.now();
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
})();
|
|
71
|
+
}
|
|
72
|
+
return publicPromise;
|
|
73
|
+
}
|
|
74
|
+
/** Authenticated session built from the saved cookie. Null when not signed in. */
|
|
75
|
+
export async function getAuthedInnertube() {
|
|
76
|
+
const cookie = readCookie();
|
|
77
|
+
if (!cookie) {
|
|
78
|
+
authedTube = null;
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (authedTube)
|
|
82
|
+
return authedTube;
|
|
83
|
+
await ensureShim();
|
|
84
|
+
const { Innertube } = await loadYoutube();
|
|
85
|
+
authedTube = await Innertube.create({ cookie });
|
|
86
|
+
return authedTube;
|
|
87
|
+
}
|
|
88
|
+
export function getMusicAuthStatus() {
|
|
89
|
+
return { authenticated: readCookie() !== null };
|
|
90
|
+
}
|
|
91
|
+
export async function saveMusicCookie(cookie) {
|
|
92
|
+
const clean = cookie.trim();
|
|
93
|
+
if (!clean)
|
|
94
|
+
throw new Error('Cookie is empty');
|
|
95
|
+
await ensureShim();
|
|
96
|
+
const { Innertube } = await loadYoutube();
|
|
97
|
+
const trial = await Innertube.create({ cookie: clean });
|
|
98
|
+
try {
|
|
99
|
+
await trial.music.getLibrary();
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
if (!isParserVariantError(err))
|
|
103
|
+
throw err;
|
|
104
|
+
logger.warn('[ytmusic] sign-in validation hit a layout variant, accepting cookie anyway');
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
fs.mkdirSync(CONFIG.ROOT, { recursive: true });
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// ignore
|
|
111
|
+
}
|
|
112
|
+
fs.writeFileSync(COOKIE_PATH, clean, 'utf-8');
|
|
113
|
+
authedTube = trial;
|
|
114
|
+
logger.info('[ytmusic] cookie sign-in validated and saved');
|
|
115
|
+
}
|
|
116
|
+
export async function signOutMusic() {
|
|
117
|
+
try {
|
|
118
|
+
if (fs.existsSync(COOKIE_PATH))
|
|
119
|
+
fs.unlinkSync(COOKIE_PATH);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// ignore
|
|
123
|
+
}
|
|
124
|
+
authedTube = null;
|
|
125
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
const isDevelopment = process.argv.includes('--dev') || process.env.NODE_ENV === 'development';
|
|
3
|
+
const logger = isDevelopment
|
|
4
|
+
? pino({
|
|
5
|
+
level: 'debug',
|
|
6
|
+
transport: {
|
|
7
|
+
target: 'pino-pretty',
|
|
8
|
+
options: {
|
|
9
|
+
colorize: true,
|
|
10
|
+
translateTime: 'SYS:standard',
|
|
11
|
+
ignore: 'pid,hostname',
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
: pino({ level: process.env.LOG_LEVEL || 'info' });
|
|
16
|
+
export default logger;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|