@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,55 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { CONFIG } from '../config.js';
|
|
5
|
+
import { DatabaseWrapper } from '../db.js';
|
|
6
|
+
import { dbGet, dbRun } from '../utils/db-utils.js';
|
|
7
|
+
export const MAL_CACHE_MAX_ROWS = 5000;
|
|
8
|
+
const DEFAULT_TTL_SECONDS = 6 * 3600;
|
|
9
|
+
function cacheFile() {
|
|
10
|
+
return path.join(CONFIG.ROOT, CONFIG.IS_DEV ? 'mal-cache.dev.db' : 'mal-cache.db');
|
|
11
|
+
}
|
|
12
|
+
let handle = null;
|
|
13
|
+
function db() {
|
|
14
|
+
if (!handle) {
|
|
15
|
+
const file = cacheFile();
|
|
16
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
17
|
+
handle = new DatabaseWrapper(file, new DatabaseSync(file));
|
|
18
|
+
dbRun(handle, `CREATE TABLE IF NOT EXISTS mal_cache (key TEXT PRIMARY KEY, payload TEXT NOT NULL, fetched_at TEXT NOT NULL, expires_at TEXT NOT NULL)`);
|
|
19
|
+
}
|
|
20
|
+
return handle;
|
|
21
|
+
}
|
|
22
|
+
export function malCacheGet(key) {
|
|
23
|
+
const row = dbGet(db(), 'SELECT payload, fetched_at AS fetchedAt, expires_at AS expiresAt FROM mal_cache WHERE key = ?', [key]);
|
|
24
|
+
if (!row)
|
|
25
|
+
return null;
|
|
26
|
+
return { payload: row.payload, fresh: Date.now() < Date.parse(row.expiresAt) };
|
|
27
|
+
}
|
|
28
|
+
export function malCachePut(key, payload, ttlSeconds = DEFAULT_TTL_SECONDS) {
|
|
29
|
+
const database = db();
|
|
30
|
+
const now = new Date();
|
|
31
|
+
dbRun(database, 'INSERT OR REPLACE INTO mal_cache (key, payload, fetched_at, expires_at) VALUES (?, ?, ?, ?)', [key, payload, now.toISOString(), new Date(now.getTime() + ttlSeconds * 1000).toISOString()]);
|
|
32
|
+
const count = dbGet(database, 'SELECT COUNT(*) AS rows FROM mal_cache')?.rows ?? 0;
|
|
33
|
+
if (count < MAL_CACHE_MAX_ROWS)
|
|
34
|
+
return;
|
|
35
|
+
malCachePruneExpired();
|
|
36
|
+
const remaining = dbGet(database, 'SELECT COUNT(*) AS rows FROM mal_cache')?.rows ?? 0;
|
|
37
|
+
if (remaining < MAL_CACHE_MAX_ROWS)
|
|
38
|
+
return;
|
|
39
|
+
dbRun(database, `DELETE FROM mal_cache WHERE key NOT IN (SELECT key FROM mal_cache ORDER BY fetched_at DESC LIMIT ${MAL_CACHE_MAX_ROWS})`);
|
|
40
|
+
}
|
|
41
|
+
export function malCachePruneExpired() {
|
|
42
|
+
const now = new Date().toISOString();
|
|
43
|
+
const row = dbGet(db(), 'SELECT COUNT(*) AS rows FROM mal_cache WHERE expires_at < ?', [now]);
|
|
44
|
+
dbRun(db(), 'DELETE FROM mal_cache WHERE expires_at < ?', [now]);
|
|
45
|
+
return row?.rows ?? 0;
|
|
46
|
+
}
|
|
47
|
+
export function malCacheDelete(key) {
|
|
48
|
+
dbRun(db(), 'DELETE FROM mal_cache WHERE key = ?', [key]);
|
|
49
|
+
}
|
|
50
|
+
export function malCacheStore() {
|
|
51
|
+
return {
|
|
52
|
+
get: (key) => malCacheGet(key),
|
|
53
|
+
put: (key, payload, ttl) => malCachePut(key, payload, ttl),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { dbAll, dbGet, dbRun } from '../utils/db-utils.js';
|
|
2
|
+
export const MANGA_STATUSES = [
|
|
3
|
+
'Reading',
|
|
4
|
+
'Completed',
|
|
5
|
+
'On-Hold',
|
|
6
|
+
'Dropped',
|
|
7
|
+
'Planned',
|
|
8
|
+
];
|
|
9
|
+
export function buildMangaId(provider, mangaId) {
|
|
10
|
+
return `${provider.toLowerCase()}:${mangaId}`;
|
|
11
|
+
}
|
|
12
|
+
export const MangaLibraryRepository = {
|
|
13
|
+
getById: (db, id) => dbGet(db, 'SELECT * FROM manga_library WHERE id = ?', [id]),
|
|
14
|
+
exists: (db, id) => {
|
|
15
|
+
const row = dbGet(db, 'SELECT EXISTS(SELECT 1 FROM manga_library WHERE id = ?) as inLibrary', [id]);
|
|
16
|
+
return !!(row && row.inLibrary);
|
|
17
|
+
},
|
|
18
|
+
getAll: (db, status, limit, offset) => {
|
|
19
|
+
let query = 'SELECT * FROM manga_library';
|
|
20
|
+
const params = [];
|
|
21
|
+
if (status && status !== 'All') {
|
|
22
|
+
query += ' WHERE status = ?';
|
|
23
|
+
params.push(status);
|
|
24
|
+
}
|
|
25
|
+
query += ' ORDER BY updatedAt DESC';
|
|
26
|
+
if (limit !== undefined && offset !== undefined) {
|
|
27
|
+
query += ' LIMIT ? OFFSET ?';
|
|
28
|
+
params.push(limit, offset);
|
|
29
|
+
}
|
|
30
|
+
return dbAll(db, query, params);
|
|
31
|
+
},
|
|
32
|
+
getCount: (db, status) => {
|
|
33
|
+
let query = 'SELECT COUNT(*) as total FROM manga_library';
|
|
34
|
+
const params = [];
|
|
35
|
+
if (status && status !== 'All') {
|
|
36
|
+
query += ' WHERE status = ?';
|
|
37
|
+
params.push(status);
|
|
38
|
+
}
|
|
39
|
+
const row = dbGet(db, query, params);
|
|
40
|
+
return row?.total || 0;
|
|
41
|
+
},
|
|
42
|
+
getIds: (db) => dbAll(db, 'SELECT id FROM manga_library').map((r) => r.id),
|
|
43
|
+
upsert: (db, data) => dbRun(db, `INSERT INTO manga_library (id, provider, mangaId, title, cover, status, author, altTitle, contentRating, updatedAt)
|
|
44
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
|
|
45
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
46
|
+
provider = COALESCE(NULLIF(EXCLUDED.provider, ''), manga_library.provider),
|
|
47
|
+
title = COALESCE(NULLIF(EXCLUDED.title, ''), manga_library.title),
|
|
48
|
+
cover = COALESCE(NULLIF(EXCLUDED.cover, ''), manga_library.cover),
|
|
49
|
+
status = COALESCE(NULLIF(EXCLUDED.status, ''), manga_library.status),
|
|
50
|
+
author = COALESCE(NULLIF(EXCLUDED.author, ''), manga_library.author),
|
|
51
|
+
altTitle = COALESCE(NULLIF(EXCLUDED.altTitle, ''), manga_library.altTitle),
|
|
52
|
+
contentRating = COALESCE(EXCLUDED.contentRating, manga_library.contentRating),
|
|
53
|
+
updatedAt = strftime('%s', 'now')`, [
|
|
54
|
+
data.id,
|
|
55
|
+
data.provider,
|
|
56
|
+
data.mangaId,
|
|
57
|
+
data.title,
|
|
58
|
+
data.cover,
|
|
59
|
+
data.status,
|
|
60
|
+
data.author || null,
|
|
61
|
+
data.altTitle || null,
|
|
62
|
+
data.contentRating || null,
|
|
63
|
+
]),
|
|
64
|
+
updateStatus: (db, id, status) => dbRun(db, "UPDATE manga_library SET status = ?, updatedAt = strftime('%s', 'now') WHERE id = ?", [status, id]),
|
|
65
|
+
updateStatusMany: (db, ids, status) => {
|
|
66
|
+
if (ids.length === 0)
|
|
67
|
+
return;
|
|
68
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
69
|
+
dbRun(db, `UPDATE manga_library SET status = ?, updatedAt = strftime('%s', 'now') WHERE id IN (${placeholders})`, [status, ...ids]);
|
|
70
|
+
},
|
|
71
|
+
touchProgress: (db, id, progress) => dbRun(db, `UPDATE manga_library SET lastChapterId = ?, lastChapterNumber = ?, lastPage = ?, updatedAt = strftime('%s', 'now') WHERE id = ?`, [progress.chapterId, progress.chapterNumber, progress.page, id]),
|
|
72
|
+
delete: (db, id) => dbRun(db, 'DELETE FROM manga_library WHERE id = ?', [id]),
|
|
73
|
+
deleteMany: (db, ids) => {
|
|
74
|
+
if (ids.length === 0)
|
|
75
|
+
return;
|
|
76
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
77
|
+
dbRun(db, `DELETE FROM manga_library WHERE id IN (${placeholders})`, ids);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
export const MangaProgressRepository = {
|
|
81
|
+
getByManga: (db, mangaId) => dbAll(db, 'SELECT mangaId, chapterId, chapterNumber, page, pageCount, updatedAt FROM manga_progress WHERE mangaId = ? ORDER BY updatedAt DESC', [mangaId]),
|
|
82
|
+
getChapter: (db, mangaId, chapterId) => dbGet(db, 'SELECT mangaId, chapterId, chapterNumber, page, pageCount, updatedAt FROM manga_progress WHERE mangaId = ? AND chapterId = ?', [mangaId, chapterId]),
|
|
83
|
+
getLatest: (db, mangaId) => dbGet(db, 'SELECT mangaId, chapterId, chapterNumber, page, pageCount, updatedAt FROM manga_progress WHERE mangaId = ? ORDER BY updatedAt DESC LIMIT 1', [mangaId]),
|
|
84
|
+
upsert: (db, data) => dbRun(db, `INSERT INTO manga_progress (mangaId, chapterId, chapterNumber, page, pageCount, title, cover, provider, altTitle, contentRating, updatedAt)
|
|
85
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
|
|
86
|
+
ON CONFLICT(mangaId, chapterId) DO UPDATE SET
|
|
87
|
+
chapterNumber = COALESCE(NULLIF(EXCLUDED.chapterNumber, ''), manga_progress.chapterNumber),
|
|
88
|
+
page = EXCLUDED.page,
|
|
89
|
+
pageCount = EXCLUDED.pageCount,
|
|
90
|
+
title = COALESCE(EXCLUDED.title, manga_progress.title),
|
|
91
|
+
cover = COALESCE(EXCLUDED.cover, manga_progress.cover),
|
|
92
|
+
provider = COALESCE(EXCLUDED.provider, manga_progress.provider),
|
|
93
|
+
altTitle = COALESCE(EXCLUDED.altTitle, manga_progress.altTitle),
|
|
94
|
+
contentRating = COALESCE(EXCLUDED.contentRating, manga_progress.contentRating),
|
|
95
|
+
updatedAt = strftime('%s', 'now')`, [
|
|
96
|
+
data.mangaId,
|
|
97
|
+
data.chapterId,
|
|
98
|
+
data.chapterNumber,
|
|
99
|
+
data.page,
|
|
100
|
+
data.pageCount,
|
|
101
|
+
data.title ?? null,
|
|
102
|
+
data.cover ?? null,
|
|
103
|
+
data.provider ?? null,
|
|
104
|
+
data.altTitle ?? null,
|
|
105
|
+
data.contentRating ?? null,
|
|
106
|
+
]),
|
|
107
|
+
deleteByManga: (db, mangaId) => dbRun(db, 'DELETE FROM manga_progress WHERE mangaId = ?', [mangaId]),
|
|
108
|
+
deleteMany: (db, ids) => {
|
|
109
|
+
if (ids.length === 0)
|
|
110
|
+
return;
|
|
111
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
112
|
+
dbRun(db, `DELETE FROM manga_progress WHERE mangaId IN (${placeholders})`, ids);
|
|
113
|
+
},
|
|
114
|
+
deleteChapter: (db, mangaId, chapterId) => dbRun(db, 'DELETE FROM manga_progress WHERE mangaId = ? AND chapterId = ?', [
|
|
115
|
+
mangaId,
|
|
116
|
+
chapterId,
|
|
117
|
+
]),
|
|
118
|
+
getContinueReading: (db, limit) => {
|
|
119
|
+
const limitClause = typeof limit === 'number' ? `LIMIT ${limit}` : '';
|
|
120
|
+
return dbAll(db, `SELECT p.mangaId as id,
|
|
121
|
+
COALESCE(l.mangaId, SUBSTR(p.mangaId, INSTR(p.mangaId, ':') + 1)) as mangaId,
|
|
122
|
+
COALESCE(l.provider, p.provider) as provider,
|
|
123
|
+
COALESCE(l.title, p.title) as title, COALESCE(l.cover, p.cover) as cover,
|
|
124
|
+
COALESCE(l.author, '') as author, COALESCE(l.altTitle, p.altTitle) as altTitle,
|
|
125
|
+
COALESCE(l.contentRating, p.contentRating) as contentRating,
|
|
126
|
+
l.status as watchlistStatus,
|
|
127
|
+
p.chapterId, p.chapterNumber, p.page, p.pageCount, p.updatedAt as progressAt
|
|
128
|
+
FROM (
|
|
129
|
+
SELECT *, ROW_NUMBER() OVER (PARTITION BY mangaId ORDER BY updatedAt DESC) as rn
|
|
130
|
+
FROM manga_progress
|
|
131
|
+
) p
|
|
132
|
+
LEFT JOIN manga_library l ON p.mangaId = l.id
|
|
133
|
+
WHERE p.rn = 1
|
|
134
|
+
AND (l.status IS NULL OR l.status = 'Reading')
|
|
135
|
+
ORDER BY p.updatedAt DESC
|
|
136
|
+
${limitClause}`);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { dbAll, dbGet, dbRun } from '../utils/db-utils.js';
|
|
2
|
+
export const NotificationsRepository = {
|
|
3
|
+
getDismissedByShow: (db, showId) => dbAll(db, 'SELECT episodeNumber FROM dismissed_notifications WHERE showId = ?', [showId]),
|
|
4
|
+
getDiscoveredByShow: (db, showId) => dbAll(db, 'SELECT episodeNumber FROM discovered_notifications WHERE showId = ?', [showId]),
|
|
5
|
+
addDiscovered: (db, showId, episodeNumber) => dbRun(db, 'INSERT OR IGNORE INTO discovered_notifications (showId, episodeNumber) VALUES (?, ?)', [showId, episodeNumber]),
|
|
6
|
+
addDismissed: (db, showId, episodeNumber) => dbRun(db, 'INSERT OR IGNORE INTO dismissed_notifications (showId, episodeNumber) VALUES (?, ?)', [showId, episodeNumber]),
|
|
7
|
+
dismissFromDiscovered: (db, showId) => {
|
|
8
|
+
if (showId) {
|
|
9
|
+
return dbRun(db, 'INSERT OR IGNORE INTO dismissed_notifications (showId, episodeNumber) SELECT showId, episodeNumber FROM discovered_notifications WHERE showId = ?', [showId]);
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
return dbRun(db, 'INSERT OR IGNORE INTO dismissed_notifications (showId, episodeNumber) SELECT showId, episodeNumber FROM discovered_notifications');
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
deleteByShow: (db, showId) => Promise.all([
|
|
16
|
+
dbRun(db, 'DELETE FROM dismissed_notifications WHERE showId = ?', [showId]),
|
|
17
|
+
dbRun(db, 'DELETE FROM discovered_notifications WHERE showId = ?', [showId]),
|
|
18
|
+
]),
|
|
19
|
+
deleteSpecificDismissed: (db, showId, episodeNumber) => dbRun(db, 'DELETE FROM dismissed_notifications WHERE showId = ? AND episodeNumber = ?', [
|
|
20
|
+
showId,
|
|
21
|
+
episodeNumber,
|
|
22
|
+
]),
|
|
23
|
+
deleteDiscovered: (db, showId, episodeNumber) => dbRun(db, 'DELETE FROM discovered_notifications WHERE showId = ? AND episodeNumber = ?', [
|
|
24
|
+
showId,
|
|
25
|
+
episodeNumber,
|
|
26
|
+
]),
|
|
27
|
+
cleanupWatchedNotifications: (db) => Promise.all([
|
|
28
|
+
dbRun(db, 'DELETE FROM dismissed_notifications WHERE EXISTS (SELECT 1 FROM watched_episodes we WHERE we.showId = dismissed_notifications.showId AND we.episodeNumber = dismissed_notifications.episodeNumber)'),
|
|
29
|
+
dbRun(db, 'DELETE FROM discovered_notifications WHERE EXISTS (SELECT 1 FROM watched_episodes we WHERE we.showId = discovered_notifications.showId AND we.episodeNumber = discovered_notifications.episodeNumber)'),
|
|
30
|
+
]),
|
|
31
|
+
hasAnyDiscovered: (db) => Promise.resolve(dbGet(db, 'SELECT COUNT(*) as count FROM discovered_notifications')).then((row) => (row?.count || 0) > 0),
|
|
32
|
+
hasActiveNotifications: (db) => Promise.resolve(dbGet(db, `SELECT COUNT(*) as count FROM discovered_notifications dn
|
|
33
|
+
WHERE NOT EXISTS (
|
|
34
|
+
SELECT 1 FROM dismissed_notifications dn2
|
|
35
|
+
WHERE dn2.showId = dn.showId AND dn2.episodeNumber = dn.episodeNumber
|
|
36
|
+
)
|
|
37
|
+
AND NOT EXISTS (
|
|
38
|
+
SELECT 1 FROM watched_episodes we
|
|
39
|
+
WHERE we.showId = dn.showId AND we.episodeNumber = dn.episodeNumber
|
|
40
|
+
)`)).then((row) => (row?.count || 0) > 0),
|
|
41
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { dbAll, dbGet, dbRun } from '../utils/db-utils.js';
|
|
2
|
+
export const QueueRepository = {
|
|
3
|
+
getAll: (db) => dbAll(db, `SELECT
|
|
4
|
+
q.id,
|
|
5
|
+
q.showId,
|
|
6
|
+
q.episodeNumber,
|
|
7
|
+
q.queue_order,
|
|
8
|
+
COALESCE(NULLIF(sm.name, ''), w.name) as name,
|
|
9
|
+
COALESCE(NULLIF(sm.thumbnail, ''), NULLIF(w.thumbnail, ''), '') as thumbnail,
|
|
10
|
+
COALESCE(NULLIF(sm.nativeName, ''), w.nativeName) as nativeName,
|
|
11
|
+
COALESCE(NULLIF(sm.englishName, ''), w.englishName) as englishName,
|
|
12
|
+
COALESCE(NULLIF(sm.type, ''), w.type) as type
|
|
13
|
+
FROM queue q
|
|
14
|
+
LEFT JOIN shows_meta sm ON q.showId = sm.id
|
|
15
|
+
LEFT JOIN watchlist w ON q.showId = w.id
|
|
16
|
+
ORDER BY q.queue_order ASC, q.id ASC`),
|
|
17
|
+
getByEpisode: (db, showId, episodeNumber) => dbGet(db, 'SELECT * FROM queue WHERE showId = ? AND episodeNumber = ?', [
|
|
18
|
+
showId,
|
|
19
|
+
episodeNumber,
|
|
20
|
+
]),
|
|
21
|
+
getByShow: (db, showId) => dbAll(db, 'SELECT * FROM queue WHERE showId = ? ORDER BY queue_order ASC', [showId]),
|
|
22
|
+
getMaxOrder: async (db) => {
|
|
23
|
+
const row = await dbGet(db, 'SELECT COALESCE(MAX(queue_order), -1) as maxOrder FROM queue');
|
|
24
|
+
return row?.maxOrder ?? -1;
|
|
25
|
+
},
|
|
26
|
+
addToEnd: (db, showId, episodeNumber) => dbRun(db, 'INSERT INTO queue (showId, episodeNumber, queue_order) VALUES (?, ?, (SELECT COALESCE(MAX(queue_order), -1) + 1 FROM queue))', [showId, episodeNumber]),
|
|
27
|
+
removeEpisode: (db, showId, episodeNumber) => dbRun(db, 'DELETE FROM queue WHERE showId = ? AND episodeNumber = ?', [showId, episodeNumber]),
|
|
28
|
+
addManyToEnd: async (db, episodes) => {
|
|
29
|
+
if (episodes.length === 0)
|
|
30
|
+
return;
|
|
31
|
+
let nextOrder = (await QueueRepository.getMaxOrder(db)) + 1;
|
|
32
|
+
for (const episode of episodes) {
|
|
33
|
+
const existing = await QueueRepository.getByEpisode(db, episode.showId, episode.episodeNumber);
|
|
34
|
+
if (existing)
|
|
35
|
+
continue;
|
|
36
|
+
await dbRun(db, 'INSERT INTO queue (showId, episodeNumber, queue_order) VALUES (?, ?, ?)', [
|
|
37
|
+
episode.showId,
|
|
38
|
+
episode.episodeNumber,
|
|
39
|
+
nextOrder,
|
|
40
|
+
]);
|
|
41
|
+
nextOrder += 1;
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
removeMany: (db, showId, episodeNumbers) => {
|
|
45
|
+
if (episodeNumbers.length === 0)
|
|
46
|
+
return Promise.resolve();
|
|
47
|
+
const placeholders = episodeNumbers.map(() => '?').join(', ');
|
|
48
|
+
return dbRun(db, `DELETE FROM queue WHERE showId = ? AND episodeNumber IN (${placeholders})`, [
|
|
49
|
+
showId,
|
|
50
|
+
...episodeNumbers,
|
|
51
|
+
]);
|
|
52
|
+
},
|
|
53
|
+
clear: (db) => dbRun(db, 'DELETE FROM queue'),
|
|
54
|
+
reorder: (db, items) => Promise.all(items.map((item, index) => {
|
|
55
|
+
if (item.id !== undefined) {
|
|
56
|
+
return dbRun(db, 'UPDATE queue SET queue_order = ? WHERE id = ?', [index, item.id]);
|
|
57
|
+
}
|
|
58
|
+
return dbRun(db, 'UPDATE queue SET queue_order = ? WHERE showId = ? AND episodeNumber = ?', [index, item.showId, item.episodeNumber]);
|
|
59
|
+
})),
|
|
60
|
+
cleanupOrphanedShowsMeta: (db) => dbRun(db, 'DELETE FROM shows_meta WHERE id NOT IN (SELECT id FROM watchlist) AND id NOT IN (SELECT showId FROM queue)'),
|
|
61
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { dbGet, dbRun } from '../utils/db-utils.js';
|
|
2
|
+
export const SettingsRepository = {
|
|
3
|
+
getByKey: (db, key) => dbGet(db, 'SELECT value FROM settings WHERE key = ?', [key]),
|
|
4
|
+
upsert: (db, key, value) => dbRun(db, 'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', [key, value]),
|
|
5
|
+
deleteByKey: (db, key) => dbRun(db, 'DELETE FROM settings WHERE key = ?', [key]),
|
|
6
|
+
clearWatchlist: (db) => dbRun(db, 'DELETE FROM watchlist'),
|
|
7
|
+
upsertWatchlistBatch: (db, shows) => {
|
|
8
|
+
for (const show of shows) {
|
|
9
|
+
dbRun(db, 'INSERT OR REPLACE INTO watchlist (id, name, thumbnail, status) VALUES (?, ?, ?, ?)', [show.id, show.name, show.thumbnail ?? null, show.status]);
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { dbGet, dbRun } from '../utils/db-utils.js';
|
|
2
|
+
export const ShowsMetaRepository = {
|
|
3
|
+
getById: (db, id) => dbGet(db, 'SELECT * FROM shows_meta WHERE id = ?', [id]),
|
|
4
|
+
getStatus: async (db, id) => {
|
|
5
|
+
const row = await dbGet(db, 'SELECT status FROM shows_meta WHERE id = ?', [
|
|
6
|
+
id,
|
|
7
|
+
]);
|
|
8
|
+
return row?.status;
|
|
9
|
+
},
|
|
10
|
+
upsert: (db, data) => dbRun(db, `INSERT INTO shows_meta (id, name, thumbnail, nativeName, englishName, genres, popularityScore, status, episodeCount, type, anilistId, isAdult, episodeDuration)
|
|
11
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
12
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
13
|
+
name = COALESCE(NULLIF(EXCLUDED.name, ''), shows_meta.name),
|
|
14
|
+
thumbnail = COALESCE(NULLIF(EXCLUDED.thumbnail, ''), shows_meta.thumbnail),
|
|
15
|
+
nativeName = COALESCE(NULLIF(EXCLUDED.nativeName, ''), shows_meta.nativeName),
|
|
16
|
+
englishName = COALESCE(NULLIF(EXCLUDED.englishName, ''), shows_meta.englishName),
|
|
17
|
+
genres = COALESCE(NULLIF(EXCLUDED.genres, ''), shows_meta.genres),
|
|
18
|
+
popularityScore = COALESCE(EXCLUDED.popularityScore, shows_meta.popularityScore),
|
|
19
|
+
status = COALESCE(NULLIF(EXCLUDED.status, ''), shows_meta.status),
|
|
20
|
+
episodeCount = COALESCE(EXCLUDED.episodeCount, shows_meta.episodeCount),
|
|
21
|
+
type = COALESCE(NULLIF(EXCLUDED.type, ''), shows_meta.type),
|
|
22
|
+
anilistId = COALESCE(EXCLUDED.anilistId, shows_meta.anilistId),
|
|
23
|
+
isAdult = COALESCE(EXCLUDED.isAdult, shows_meta.isAdult),
|
|
24
|
+
episodeDuration = COALESCE(NULLIF(EXCLUDED.episodeDuration, 0), shows_meta.episodeDuration)`, [
|
|
25
|
+
data.id,
|
|
26
|
+
data.name ?? null,
|
|
27
|
+
data.thumbnail ?? null,
|
|
28
|
+
data.nativeName ?? null,
|
|
29
|
+
data.englishName ?? null,
|
|
30
|
+
data.genres ?? null,
|
|
31
|
+
data.popularityScore ?? null,
|
|
32
|
+
data.status ?? null,
|
|
33
|
+
data.episodeCount ?? null,
|
|
34
|
+
data.type ?? null,
|
|
35
|
+
data.anilistId ?? null,
|
|
36
|
+
data.isAdult ?? null,
|
|
37
|
+
data.episodeDuration ?? null,
|
|
38
|
+
]),
|
|
39
|
+
updateEpisodeCount: (db, id, episodeCount) => dbRun(db, 'UPDATE shows_meta SET episodeCount = ? WHERE id = ?', [episodeCount, id]),
|
|
40
|
+
updateStatus: (db, id, status) => dbRun(db, 'UPDATE shows_meta SET status = ? WHERE id = ?', [status, id]),
|
|
41
|
+
cleanupOrphanedMeta: (db) => dbRun(db, 'DELETE FROM shows_meta WHERE id NOT IN (SELECT id FROM watchlist) AND id NOT IN (SELECT showId FROM queue)'),
|
|
42
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { dbGet, dbRun } from '../utils/db-utils.js';
|
|
2
|
+
import { TEMP_SHOW_ID_PREFIX } from '../lib/temp-ids.js';
|
|
3
|
+
export const TempShowIdsRepository = {
|
|
4
|
+
getById: (db, id) => dbGet(db, 'SELECT * FROM temp_show_ids WHERE id = ?', [id]),
|
|
5
|
+
getByProviderNative: (db, provider, nativeId) => dbGet(db, 'SELECT * FROM temp_show_ids WHERE provider = ? AND nativeId = ?', [
|
|
6
|
+
provider,
|
|
7
|
+
nativeId,
|
|
8
|
+
]),
|
|
9
|
+
count: (db) => {
|
|
10
|
+
const row = dbGet(db, 'SELECT COUNT(*) as total FROM temp_show_ids');
|
|
11
|
+
return row?.total || 0;
|
|
12
|
+
},
|
|
13
|
+
allocate: (db, data) => {
|
|
14
|
+
const existing = TempShowIdsRepository.getByProviderNative(db, data.provider, data.nativeId);
|
|
15
|
+
if (existing)
|
|
16
|
+
return existing;
|
|
17
|
+
const prefixLen = TEMP_SHOW_ID_PREFIX.length + 1;
|
|
18
|
+
const maxRow = dbGet(db, `SELECT MAX(CAST(SUBSTR(id, ?) AS INTEGER)) as maxId FROM temp_show_ids WHERE id LIKE '${TEMP_SHOW_ID_PREFIX}%'`, [prefixLen]);
|
|
19
|
+
const next = (maxRow?.maxId || 0) + 1;
|
|
20
|
+
const id = `${TEMP_SHOW_ID_PREFIX}${next}`;
|
|
21
|
+
try {
|
|
22
|
+
dbRun(db, 'INSERT INTO temp_show_ids (id, provider, nativeId, title, thumbnail, createdAt) VALUES (?, ?, ?, ?, ?, ?)', [id, data.provider, data.nativeId, data.title, data.thumbnail || '', Date.now()]);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Lost a race with a concurrent allocate — return whatever won.
|
|
26
|
+
const winner = TempShowIdsRepository.getByProviderNative(db, data.provider, data.nativeId);
|
|
27
|
+
if (winner)
|
|
28
|
+
return winner;
|
|
29
|
+
throw new Error('temp id allocation failed');
|
|
30
|
+
}
|
|
31
|
+
return TempShowIdsRepository.getById(db, id);
|
|
32
|
+
},
|
|
33
|
+
purge: (db) => {
|
|
34
|
+
const total = TempShowIdsRepository.count(db);
|
|
35
|
+
if (total === 0)
|
|
36
|
+
return 0;
|
|
37
|
+
const like = `${TEMP_SHOW_ID_PREFIX}%`;
|
|
38
|
+
dbRun(db, 'DELETE FROM watchlist WHERE id LIKE ?', [like]);
|
|
39
|
+
dbRun(db, 'DELETE FROM watched_episodes WHERE showId LIKE ?', [like]);
|
|
40
|
+
dbRun(db, 'DELETE FROM queue WHERE showId LIKE ?', [like]);
|
|
41
|
+
dbRun(db, 'DELETE FROM shows_meta WHERE id LIKE ?', [like]);
|
|
42
|
+
dbRun(db, 'DELETE FROM dismissed_notifications WHERE showId LIKE ?', [like]);
|
|
43
|
+
dbRun(db, 'DELETE FROM discovered_notifications WHERE showId LIKE ?', [like]);
|
|
44
|
+
dbRun(db, 'DELETE FROM legacy_id_mapping WHERE legacyId LIKE ? OR numericId LIKE ?', [
|
|
45
|
+
like,
|
|
46
|
+
like,
|
|
47
|
+
]);
|
|
48
|
+
dbRun(db, 'DELETE FROM temp_show_ids');
|
|
49
|
+
return total;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { dbAll, dbGet, dbRun } from '../utils/db-utils.js';
|
|
2
|
+
export const TV_STATUSES = ['Watching', 'Completed', 'On-Hold', 'Dropped', 'Planned'];
|
|
3
|
+
export function buildTvId(mediaType, tmdbId) {
|
|
4
|
+
const t = String(mediaType).toLowerCase() === 'movie' ? 'movie' : 'tv';
|
|
5
|
+
return `${t}:${tmdbId}`;
|
|
6
|
+
}
|
|
7
|
+
export function normalizeTvMediaId(raw) {
|
|
8
|
+
const s = String(raw ?? '').trim();
|
|
9
|
+
const m = /^(movie|tv)[:-](\d+)$/i.exec(s);
|
|
10
|
+
if (m)
|
|
11
|
+
return `${m[1].toLowerCase()}:${m[2]}`;
|
|
12
|
+
return s;
|
|
13
|
+
}
|
|
14
|
+
export const TvLibraryRepository = {
|
|
15
|
+
getById: (db, id) => dbGet(db, 'SELECT * FROM tv_library WHERE id = ?', [id]),
|
|
16
|
+
exists: (db, id) => {
|
|
17
|
+
const row = dbGet(db, 'SELECT EXISTS(SELECT 1 FROM tv_library WHERE id = ?) as inLibrary', [id]);
|
|
18
|
+
return !!(row && row.inLibrary);
|
|
19
|
+
},
|
|
20
|
+
getAll: (db, status, limit, offset) => {
|
|
21
|
+
let query = 'SELECT * FROM tv_library';
|
|
22
|
+
const params = [];
|
|
23
|
+
if (status && status !== 'All') {
|
|
24
|
+
query += ' WHERE status = ?';
|
|
25
|
+
params.push(status);
|
|
26
|
+
}
|
|
27
|
+
query += ' ORDER BY updatedAt DESC';
|
|
28
|
+
if (limit !== undefined && offset !== undefined) {
|
|
29
|
+
query += ' LIMIT ? OFFSET ?';
|
|
30
|
+
params.push(limit, offset);
|
|
31
|
+
}
|
|
32
|
+
return dbAll(db, query, params);
|
|
33
|
+
},
|
|
34
|
+
getCount: (db, status) => {
|
|
35
|
+
let query = 'SELECT COUNT(*) as total FROM tv_library';
|
|
36
|
+
const params = [];
|
|
37
|
+
if (status && status !== 'All') {
|
|
38
|
+
query += ' WHERE status = ?';
|
|
39
|
+
params.push(status);
|
|
40
|
+
}
|
|
41
|
+
const row = dbGet(db, query, params);
|
|
42
|
+
return row?.total || 0;
|
|
43
|
+
},
|
|
44
|
+
getIds: (db) => dbAll(db, 'SELECT id FROM tv_library').map((r) => r.id),
|
|
45
|
+
upsert: (db, data) => dbRun(db, `INSERT INTO tv_library (id, tmdbId, mediaType, title, poster, backdrop, year, overview, status, adult, updatedAt)
|
|
46
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
|
|
47
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
48
|
+
tmdbId = EXCLUDED.tmdbId,
|
|
49
|
+
mediaType = EXCLUDED.mediaType,
|
|
50
|
+
title = COALESCE(NULLIF(EXCLUDED.title, ''), tv_library.title),
|
|
51
|
+
poster = COALESCE(NULLIF(EXCLUDED.poster, ''), tv_library.poster),
|
|
52
|
+
backdrop = COALESCE(NULLIF(EXCLUDED.backdrop, ''), tv_library.backdrop),
|
|
53
|
+
year = COALESCE(NULLIF(EXCLUDED.year, ''), tv_library.year),
|
|
54
|
+
overview = COALESCE(NULLIF(EXCLUDED.overview, ''), tv_library.overview),
|
|
55
|
+
status = COALESCE(NULLIF(EXCLUDED.status, ''), tv_library.status),
|
|
56
|
+
adult = COALESCE(EXCLUDED.adult, tv_library.adult),
|
|
57
|
+
updatedAt = strftime('%s', 'now')`, [
|
|
58
|
+
data.id,
|
|
59
|
+
data.tmdbId,
|
|
60
|
+
data.mediaType,
|
|
61
|
+
data.title,
|
|
62
|
+
data.poster,
|
|
63
|
+
data.backdrop || null,
|
|
64
|
+
data.year || null,
|
|
65
|
+
data.overview || null,
|
|
66
|
+
data.status,
|
|
67
|
+
data.adult ? 1 : 0,
|
|
68
|
+
]),
|
|
69
|
+
updateStatus: (db, id, status) => dbRun(db, "UPDATE tv_library SET status = ?, updatedAt = strftime('%s', 'now') WHERE id = ?", [
|
|
70
|
+
status,
|
|
71
|
+
id,
|
|
72
|
+
]),
|
|
73
|
+
updateStatusMany: (db, ids, status) => {
|
|
74
|
+
if (ids.length === 0)
|
|
75
|
+
return;
|
|
76
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
77
|
+
dbRun(db, `UPDATE tv_library SET status = ?, updatedAt = strftime('%s', 'now') WHERE id IN (${placeholders})`, [status, ...ids]);
|
|
78
|
+
},
|
|
79
|
+
touchProgress: (db, id, progress) => dbRun(db, `UPDATE tv_library SET lastSeason = ?, lastEpisode = ?, updatedAt = strftime('%s', 'now') WHERE id = ?`, [progress.season, progress.episode, id]),
|
|
80
|
+
delete: (db, id) => dbRun(db, 'DELETE FROM tv_library WHERE id = ?', [id]),
|
|
81
|
+
deleteMany: (db, ids) => {
|
|
82
|
+
if (ids.length === 0)
|
|
83
|
+
return;
|
|
84
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
85
|
+
dbRun(db, `DELETE FROM tv_library WHERE id IN (${placeholders})`, ids);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
export const TvProgressRepository = {
|
|
89
|
+
getByMedia: (db, mediaId) => dbAll(db, 'SELECT mediaId, season, episode, currentTime, duration, updatedAt FROM tv_progress WHERE mediaId = ? ORDER BY updatedAt DESC', [mediaId]),
|
|
90
|
+
getEpisode: (db, mediaId, season, episode) => dbGet(db, 'SELECT mediaId, season, episode, currentTime, duration, updatedAt FROM tv_progress WHERE mediaId = ? AND season = ? AND episode = ?', [mediaId, season, episode]),
|
|
91
|
+
getLatest: (db, mediaId) => dbGet(db, 'SELECT mediaId, season, episode, currentTime, duration, updatedAt FROM tv_progress WHERE mediaId = ? ORDER BY updatedAt DESC LIMIT 1', [mediaId]),
|
|
92
|
+
upsert: (db, data) => dbRun(db, `INSERT INTO tv_progress (mediaId, season, episode, currentTime, duration, title, poster, backdrop, year, overview, tmdbId, mediaType, adult, updatedAt)
|
|
93
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
|
|
94
|
+
ON CONFLICT(mediaId, season, episode) DO UPDATE SET
|
|
95
|
+
currentTime = EXCLUDED.currentTime,
|
|
96
|
+
duration = EXCLUDED.duration,
|
|
97
|
+
title = COALESCE(EXCLUDED.title, tv_progress.title),
|
|
98
|
+
poster = COALESCE(EXCLUDED.poster, tv_progress.poster),
|
|
99
|
+
backdrop = COALESCE(EXCLUDED.backdrop, tv_progress.backdrop),
|
|
100
|
+
year = COALESCE(EXCLUDED.year, tv_progress.year),
|
|
101
|
+
overview = COALESCE(EXCLUDED.overview, tv_progress.overview),
|
|
102
|
+
tmdbId = COALESCE(EXCLUDED.tmdbId, tv_progress.tmdbId),
|
|
103
|
+
mediaType = COALESCE(EXCLUDED.mediaType, tv_progress.mediaType),
|
|
104
|
+
adult = COALESCE(EXCLUDED.adult, tv_progress.adult),
|
|
105
|
+
updatedAt = strftime('%s', 'now')`, [
|
|
106
|
+
data.mediaId,
|
|
107
|
+
data.season,
|
|
108
|
+
data.episode,
|
|
109
|
+
data.currentTime,
|
|
110
|
+
data.duration,
|
|
111
|
+
data.title ?? null,
|
|
112
|
+
data.poster ?? null,
|
|
113
|
+
data.backdrop ?? null,
|
|
114
|
+
data.year ?? null,
|
|
115
|
+
data.overview ?? null,
|
|
116
|
+
data.tmdbId ?? null,
|
|
117
|
+
data.mediaType ?? null,
|
|
118
|
+
data.adult ?? null,
|
|
119
|
+
]),
|
|
120
|
+
deleteByMedia: (db, mediaId) => dbRun(db, 'DELETE FROM tv_progress WHERE mediaId = ?', [mediaId]),
|
|
121
|
+
deleteMany: (db, ids) => {
|
|
122
|
+
if (ids.length === 0)
|
|
123
|
+
return;
|
|
124
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
125
|
+
dbRun(db, `DELETE FROM tv_progress WHERE mediaId IN (${placeholders})`, ids);
|
|
126
|
+
},
|
|
127
|
+
deleteEpisode: (db, mediaId, season, episode) => dbRun(db, 'DELETE FROM tv_progress WHERE mediaId = ? AND season = ? AND episode = ?', [
|
|
128
|
+
mediaId,
|
|
129
|
+
season,
|
|
130
|
+
episode,
|
|
131
|
+
]),
|
|
132
|
+
getContinueWatching: (db, limit) => {
|
|
133
|
+
const limitClause = typeof limit === 'number' ? `LIMIT ${limit}` : '';
|
|
134
|
+
return dbAll(db, `SELECT p.mediaId as id,
|
|
135
|
+
COALESCE(l.tmdbId, p.tmdbId, CAST(SUBSTR(REPLACE(p.mediaId, '-', ':'), INSTR(REPLACE(p.mediaId, '-', ':'), ':') + 1) AS INTEGER)) as tmdbId,
|
|
136
|
+
COALESCE(l.mediaType, p.mediaType, SUBSTR(REPLACE(p.mediaId, '-', ':'), 1, INSTR(REPLACE(p.mediaId, '-', ':'), ':') - 1)) as mediaType,
|
|
137
|
+
COALESCE(l.title, p.title) as title, COALESCE(l.poster, p.poster) as poster, COALESCE(l.backdrop, p.backdrop) as backdrop,
|
|
138
|
+
COALESCE(l.year, p.year) as year, COALESCE(l.overview, p.overview) as overview,
|
|
139
|
+
l.status as watchlistStatus, COALESCE(l.adult, p.adult) as adult,
|
|
140
|
+
p.season, p.episode, p.currentTime, p.duration, p.updatedAt as progressAt
|
|
141
|
+
FROM (
|
|
142
|
+
SELECT *, ROW_NUMBER() OVER (PARTITION BY mediaId ORDER BY updatedAt DESC) as rn
|
|
143
|
+
FROM tv_progress
|
|
144
|
+
) p
|
|
145
|
+
LEFT JOIN tv_library l ON p.mediaId = l.id
|
|
146
|
+
WHERE p.rn = 1
|
|
147
|
+
AND (l.status IS NULL OR l.status = 'Watching')
|
|
148
|
+
ORDER BY p.updatedAt DESC
|
|
149
|
+
${limitClause}`);
|
|
150
|
+
},
|
|
151
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { dbAll, dbGet, dbRun } from '../utils/db-utils.js';
|
|
2
|
+
export const WatchedEpisodesRepository = {
|
|
3
|
+
getByShowAndEpisode: (db, showId, episodeNumber) => dbGet(db, 'SELECT currentTime, duration FROM watched_episodes WHERE showId = ? AND episodeNumber = ?', [showId, episodeNumber]),
|
|
4
|
+
getWatchedEpisodeNumbers: async (db, showId) => {
|
|
5
|
+
const rows = await dbAll(db, 'SELECT episodeNumber FROM watched_episodes WHERE showId = ?', [showId]);
|
|
6
|
+
return rows.map((r) => r.episodeNumber);
|
|
7
|
+
},
|
|
8
|
+
getByShow: (db, showId) => dbAll(db, 'SELECT showId, episodeNumber, currentTime, duration, watchedAt FROM watched_episodes WHERE showId = ? ORDER BY CAST(episodeNumber AS REAL) ASC', [showId]),
|
|
9
|
+
getLatestResumeProgress: (db, showId) => dbGet(db, `SELECT showId, episodeNumber, currentTime, duration, watchedAt
|
|
10
|
+
FROM watched_episodes
|
|
11
|
+
WHERE showId = ? AND currentTime > 5 AND (duration <= 0 OR currentTime < duration * 0.8)
|
|
12
|
+
ORDER BY watchedAt DESC
|
|
13
|
+
LIMIT 1`, [showId]),
|
|
14
|
+
upsert: (db, data) => dbRun(db, 'INSERT OR REPLACE INTO watched_episodes (showId, episodeNumber, watchedAt, currentTime, duration) VALUES (?, ?, CURRENT_TIMESTAMP, ?, ?)', [data.showId, data.episodeNumber, data.currentTime, data.duration]),
|
|
15
|
+
insertIfMissing: (db, data) => dbRun(db, 'INSERT OR IGNORE INTO watched_episodes (showId, episodeNumber, watchedAt, currentTime, duration) VALUES (?, ?, COALESCE(?, CURRENT_TIMESTAMP), 0, 0)', [data.showId, data.episodeNumber, data.watchedAt ?? null]),
|
|
16
|
+
deleteByShow: (db, showId) => dbRun(db, 'DELETE FROM watched_episodes WHERE showId = ?', [showId]),
|
|
17
|
+
getContinueWatching: (db, limit) => {
|
|
18
|
+
const limitClause = typeof limit === 'number' ? `LIMIT ${limit}` : '';
|
|
19
|
+
const query = `
|
|
20
|
+
SELECT
|
|
21
|
+
we.showId as _id,
|
|
22
|
+
we.showId as id,
|
|
23
|
+
COALESCE(NULLIF(w.name, ''), sm.name) as name,
|
|
24
|
+
COALESCE(NULLIF(w.thumbnail, ''), NULLIF(sm.thumbnail, ''), '') as thumbnail,
|
|
25
|
+
COALESCE(NULLIF(w.nativeName, ''), sm.nativeName) as nativeName,
|
|
26
|
+
COALESCE(NULLIF(w.englishName, ''), sm.englishName) as englishName,
|
|
27
|
+
COALESCE(NULLIF(w.type, ''), sm.type) as type,
|
|
28
|
+
sm.episodeCount,
|
|
29
|
+
sm.type as smType,
|
|
30
|
+
sm.isAdult as isAdult,
|
|
31
|
+
w.status as watchlistStatus,
|
|
32
|
+
(SELECT COUNT(DISTINCT episodeNumber) FROM watched_episodes WHERE showId = we.showId) as watchedCount,
|
|
33
|
+
we.episodeNumber, we.currentTime, we.duration, we.watchedAt
|
|
34
|
+
FROM (
|
|
35
|
+
SELECT *, ROW_NUMBER() OVER(PARTITION BY showId ORDER BY (currentTime > 0 OR duration > 0) DESC, watchedAt DESC) as rn
|
|
36
|
+
FROM watched_episodes
|
|
37
|
+
) we
|
|
38
|
+
LEFT JOIN watchlist w ON we.showId = w.id
|
|
39
|
+
LEFT JOIN shows_meta sm ON we.showId = sm.id
|
|
40
|
+
WHERE we.rn = 1
|
|
41
|
+
AND (w.status IS NULL OR w.status = 'Watching')
|
|
42
|
+
AND (w.id IS NOT NULL OR sm.id IS NOT NULL)
|
|
43
|
+
ORDER BY we.watchedAt DESC
|
|
44
|
+
${limitClause}
|
|
45
|
+
`;
|
|
46
|
+
return dbAll(db, query);
|
|
47
|
+
},
|
|
48
|
+
getEpisodesForShows: (db, showIds) => {
|
|
49
|
+
const placeholders = showIds.map(() => '?').join(',');
|
|
50
|
+
return dbAll(db, `SELECT showId, episodeNumber, currentTime, duration, watchedAt FROM watched_episodes WHERE showId IN (${placeholders})`, showIds);
|
|
51
|
+
},
|
|
52
|
+
};
|