Skip to content
This repository was archived by the owner on Feb 3, 2025. It is now read-only.

Commit 6b4dd53

Browse files
committed
feat: show out of sync chapters for each manga
1 parent 49c1f49 commit 6b4dd53

File tree

7 files changed

+196
-27
lines changed

7 files changed

+196
-27
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- CreateTable
2+
CREATE TABLE "OutOfSyncChapter" (
3+
"id" INTEGER NOT NULL,
4+
"mangaId" INTEGER NOT NULL,
5+
6+
CONSTRAINT "OutOfSyncChapter_pkey" PRIMARY KEY ("id")
7+
);
8+
9+
-- AddForeignKey
10+
ALTER TABLE "OutOfSyncChapter" ADD CONSTRAINT "OutOfSyncChapter_mangaId_fkey" FOREIGN KEY ("mangaId") REFERENCES "Manga"("id") ON DELETE CASCADE ON UPDATE CASCADE;

prisma/schema.prisma

+17-10
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,17 @@ model Library {
1919
}
2020

2121
model Manga {
22-
id Int @id @default(autoincrement())
23-
createdAt DateTime @default(now())
24-
title String @unique
25-
interval String
26-
source String
27-
library Library @relation(fields: [libraryId], references: [id], onDelete: Cascade)
28-
libraryId Int
29-
chapters Chapter[]
30-
metadata Metadata @relation(fields: [metadataId], references: [id])
31-
metadataId Int @unique
22+
id Int @id @default(autoincrement())
23+
createdAt DateTime @default(now())
24+
title String @unique
25+
interval String
26+
source String
27+
library Library @relation(fields: [libraryId], references: [id], onDelete: Cascade)
28+
libraryId Int
29+
chapters Chapter[]
30+
metadata Metadata @relation(fields: [metadataId], references: [id])
31+
metadataId Int @unique
32+
outOfSyncChapters OutOfSyncChapter[]
3233
}
3334

3435
model Chapter {
@@ -41,6 +42,12 @@ model Chapter {
4142
mangaId Int
4243
}
4344

45+
model OutOfSyncChapter {
46+
id Int @id
47+
manga Manga @relation(fields: [mangaId], references: [id], onDelete: Cascade)
48+
mangaId Int
49+
}
50+
4451
model Metadata {
4552
id Int @id @default(autoincrement())
4653
createdAt DateTime @default(now())

src/components/chaptersTable.tsx

+49-4
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,28 @@ import { DataTable } from 'mantine-datatable';
33

44
import dayjs from 'dayjs';
55

6+
import { ActionIcon, Center, Tooltip } from '@mantine/core';
7+
import { IconAlertTriangle, IconCheck, IconRefresh } from '@tabler/icons-react';
68
import prettyBytes from 'pretty-bytes';
79
import { useEffect, useState } from 'react';
810

9-
const mangaWithMetadataAndChaptersLibrary = Prisma.validator<Prisma.MangaArgs>()({
10-
include: { metadata: true, chapters: true, library: true },
11+
const mangaWithMetadataAndChaptersAndOutOfSyncChaptersAndLibrary = Prisma.validator<Prisma.MangaArgs>()({
12+
include: { metadata: true, chapters: true, library: true, outOfSyncChapters: true },
1113
});
1214

13-
export type MangaWithMetadataAndChaptersLibrary = Prisma.MangaGetPayload<typeof mangaWithMetadataAndChaptersLibrary>;
15+
export type MangaWithMetadataAndChaptersAndOutOfSyncChaptersAndLibrary = Prisma.MangaGetPayload<
16+
typeof mangaWithMetadataAndChaptersAndOutOfSyncChaptersAndLibrary
17+
>;
1418

1519
const PAGE_SIZE = 100;
1620

17-
export function ChaptersTable({ manga }: { manga: MangaWithMetadataAndChaptersLibrary }) {
21+
export function ChaptersTable({
22+
manga,
23+
onCheckOutOfSync,
24+
}: {
25+
manga: MangaWithMetadataAndChaptersAndOutOfSyncChaptersAndLibrary;
26+
onCheckOutOfSync: () => void;
27+
}) {
1828
const [page, setPage] = useState(1);
1929
const [records, setRecords] = useState(manga.chapters.slice(0, PAGE_SIZE));
2030

@@ -49,6 +59,41 @@ export function ChaptersTable({ manga }: { manga: MangaWithMetadataAndChaptersLi
4959
render: ({ fileName }) => `${fileName}`,
5060
},
5161
{ accessor: 'size', title: 'File Size', render: ({ size }) => prettyBytes(size) },
62+
{
63+
accessor: '',
64+
title: (
65+
<Center>
66+
<span>Status</span>
67+
<Tooltip withArrow label="Check for out of sync chapters">
68+
<ActionIcon
69+
variant="transparent"
70+
color="blue"
71+
onClick={(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
72+
e.stopPropagation();
73+
onCheckOutOfSync();
74+
}}
75+
>
76+
<IconRefresh size={16} />
77+
</ActionIcon>
78+
</Tooltip>
79+
</Center>
80+
),
81+
width: 90,
82+
render: ({ id }) =>
83+
manga.outOfSyncChapters.find((c) => c.id === id) ? (
84+
<Tooltip withArrow label="This chapter is out of sync with the source.">
85+
<Center>
86+
<IconAlertTriangle color="red" size={18} strokeWidth={2} />
87+
</Center>
88+
</Tooltip>
89+
) : (
90+
<Tooltip withArrow label="This chapter is in sync with the source.">
91+
<Center>
92+
<IconCheck color="green" size={18} strokeWidth={3} />
93+
</Center>
94+
</Tooltip>
95+
),
96+
},
5297
]}
5398
/>
5499
);

src/components/mangaCard.tsx

+50-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { ActionIcon, Badge, createStyles, Paper, Skeleton, Title, Tooltip } from '@mantine/core';
22
import { Prisma } from '@prisma/client';
3-
import { IconEdit, IconRefresh, IconX } from '@tabler/icons-react';
3+
import { IconEdit, IconExclamationMark, IconRefresh, IconX } from '@tabler/icons-react';
44
import { contrastColor } from 'contrast-color';
5+
import { motion } from 'framer-motion';
56
import stc from 'string-to-color';
67
import { useRefreshModal } from './refreshMetadata';
78
import { useRemoveModal } from './removeManga';
@@ -59,6 +60,19 @@ const useStyles = createStyles((theme, _params, getRef) => ({
5960
backgroundColor: theme.colors.gray[0],
6061
},
6162
},
63+
outOfSyncClass: {
64+
position: 'absolute',
65+
alignContent: 'center',
66+
display: 'flex',
67+
flexWrap: 'wrap',
68+
backgroundColor: theme.colors.red[6],
69+
border: `2px solid ${theme.white}`,
70+
left: 8,
71+
bottom: 8,
72+
borderRadius: 9999,
73+
width: 24,
74+
height: 24,
75+
},
6276
editButton: {
6377
ref: getRef('editButton'),
6478
backgroundColor: theme.white,
@@ -85,14 +99,16 @@ const useStyles = createStyles((theme, _params, getRef) => ({
8599
},
86100
}));
87101

88-
const mangaWithLibraryAndMetadata = Prisma.validator<Prisma.MangaArgs>()({
89-
include: { library: true, metadata: true },
102+
const mangaWithLibraryAndMetadataAndOutOfSyncChapters = Prisma.validator<Prisma.MangaArgs>()({
103+
include: { library: true, metadata: true, outOfSyncChapters: true },
90104
});
91105

92-
type MangaWithLibraryAndMetadata = Prisma.MangaGetPayload<typeof mangaWithLibraryAndMetadata>;
106+
type MangaWithLibraryAndMetadataAndOutOfSyncChapters = Prisma.MangaGetPayload<
107+
typeof mangaWithLibraryAndMetadataAndOutOfSyncChapters
108+
>;
93109

94110
interface MangaCardProps {
95-
manga: MangaWithLibraryAndMetadata;
111+
manga: MangaWithLibraryAndMetadataAndOutOfSyncChapters;
96112
onRemove: (shouldRemoveFiles: boolean) => void;
97113
onUpdate: () => void;
98114
onRefresh: () => void;
@@ -163,6 +179,35 @@ export function MangaCard({ manga, onRemove, onUpdate, onRefresh, onClick }: Man
163179
<IconEdit size={18} />
164180
</ActionIcon>
165181
</Tooltip>
182+
183+
{manga.outOfSyncChapters.length > 0 && (
184+
<Tooltip withinPortal withArrow label="This manga has out of sync chapters." position="right">
185+
<motion.div
186+
className={classes.outOfSyncClass}
187+
initial={{
188+
scale: 1,
189+
}}
190+
animate={{
191+
scale: [1.1, 1.0],
192+
}}
193+
exit={{
194+
scale: 1,
195+
}}
196+
transition={{
197+
duration: 1,
198+
repeat: Infinity,
199+
repeatType: 'loop',
200+
type: 'spring',
201+
stiffness: 400,
202+
damping: 10,
203+
repeatDelay: 3,
204+
}}
205+
>
206+
<IconExclamationMark color="white" strokeWidth={3} />
207+
</motion.div>
208+
</Tooltip>
209+
)}
210+
166211
<div>
167212
<Badge
168213
sx={{ backgroundColor: stc(manga.source), color: contrastColor({ bgColor: stc(manga.source) }) }}

src/pages/manga/[id].tsx

+11-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { trpc } from '../../utils/trpc';
77
export default function MangaPage() {
88
const router = useRouter();
99
const { id } = router.query;
10+
11+
const checkOutOfSyncChaptersMutation = trpc.manga.checkOutOfSyncChapters.useMutation();
1012
const mangaQuery = trpc.manga.get.useQuery(
1113
{
1214
id: parseInt(id as string, 10),
@@ -32,7 +34,15 @@ export default function MangaPage() {
3234
<MangaDetail manga={mangaQuery.data} />
3335
</Box>
3436
<Box sx={{ marginTop: 20, overflow: 'hidden', flex: 1 }}>
35-
<ChaptersTable manga={mangaQuery.data} />
37+
<ChaptersTable
38+
manga={mangaQuery.data}
39+
onCheckOutOfSync={async () => {
40+
await checkOutOfSyncChaptersMutation.mutateAsync({
41+
id: mangaQuery.data.id,
42+
});
43+
mangaQuery.refetch();
44+
}}
45+
/>
3646
</Box>
3747
</Box>
3848
);

src/server/trpc/router/manga.ts

+41-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { OutOfSyncChapter } from '@prisma/client';
12
import { TRPCError } from '@trpc/server';
23
import path from 'path';
34
import { z } from 'zod';
@@ -11,14 +12,18 @@ import {
1112
getAvailableSources,
1213
getMangaDetail,
1314
getMangaMetadata,
15+
getOutOfSyncChapters,
1416
removeManga,
1517
search,
1618
} from '../../utils/mangal';
1719
import { t } from '../trpc';
1820

1921
export const mangaRouter = t.router({
2022
query: t.procedure.query(async ({ ctx }) => {
21-
return ctx.prisma.manga.findMany({ include: { metadata: true, library: true }, orderBy: { title: 'asc' } });
23+
return ctx.prisma.manga.findMany({
24+
include: { metadata: true, library: true, outOfSyncChapters: true },
25+
orderBy: { title: 'asc' },
26+
});
2227
}),
2328
sources: t.procedure.query(async () => {
2429
return getAvailableSources();
@@ -62,6 +67,7 @@ export const mangaRouter = t.router({
6267
},
6368
library: true,
6469
metadata: true,
70+
outOfSyncChapters: true,
6571
},
6672
where: { id },
6773
});
@@ -364,4 +370,38 @@ export const mangaRouter = t.router({
364370

365371
return ctx.prisma.manga.findUniqueOrThrow({ include: { metadata: true, library: true }, where: { id } });
366372
}),
373+
checkOutOfSyncChapters: t.procedure
374+
.input(
375+
z.object({
376+
id: z.number().nullish(),
377+
}),
378+
)
379+
.mutation(async ({ input, ctx }) => {
380+
const { id } = input;
381+
if (id) {
382+
const mangaInDb = await ctx.prisma.manga.findUniqueOrThrow({
383+
include: { library: true, chapters: true },
384+
where: { id },
385+
});
386+
const mangaDir = path.resolve(mangaInDb.library.path, sanitizer(mangaInDb.title));
387+
const outOfSyncChapters = await getOutOfSyncChapters(mangaDir, mangaInDb.source, mangaInDb.title);
388+
389+
await ctx.prisma.outOfSyncChapter.deleteMany({ where: { mangaId: mangaInDb.id } });
390+
await ctx.prisma.outOfSyncChapter.createMany({
391+
data: outOfSyncChapters
392+
.map((outOfSyncChapterFile) => {
393+
const chapter = mangaInDb.chapters.find((c) => c.fileName === outOfSyncChapterFile);
394+
if (!chapter) {
395+
return undefined;
396+
}
397+
398+
return {
399+
id: chapter.id,
400+
mangaId: mangaInDb.id,
401+
};
402+
})
403+
.filter((c) => c) as OutOfSyncChapter[],
404+
});
405+
}
406+
}),
367407
});

src/server/utils/mangal.ts

+18-6
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ export const search = async (source: string, keyword: string): Promise<IOutput>
188188
};
189189
};
190190

191-
export const getChaptersFromRemote = async (source: string, title: string): Promise<number[]> => {
191+
export const getChaptersFromRemote = async (source: string, title: string): Promise<Chapter[]> => {
192192
try {
193193
const { stdout, escapedCommand } = await execa('mangal', [
194194
'inline',
@@ -210,7 +210,7 @@ export const getChaptersFromRemote = async (source: string, title: string): Prom
210210
output.result[0]?.mangal.chapters &&
211211
output.result[0]?.mangal.chapters.length > 0
212212
) {
213-
return output.result[0].mangal.chapters.map((c) => c.index - 1);
213+
return output.result[0].mangal.chapters.map((c) => ({ ...c, index: c.index - 1 }));
214214
}
215215
} catch (err) {
216216
logger.error(err);
@@ -302,7 +302,7 @@ export const downloadChapter = async (
302302
}
303303
};
304304

305-
const getChapterIndexFromFile = (chapterFile: string) => {
305+
export const getChapterIndexFromFile = (chapterFile: string) => {
306306
const indexRegexp = /.*?\[(\d+)\].*/;
307307
const match = indexRegexp.exec(path.basename(chapterFile));
308308
if (!match || match.length < 2 || !match[1]) {
@@ -346,12 +346,14 @@ export const findMissingChapterFiles = async (mangaDir: string, source: string,
346346
throw new Error();
347347
}
348348
await fs.mkdir(mangaDir, { recursive: true });
349-
const chapters = await fs.readdir(mangaDir);
350349

351-
const localChapters = chapters.filter(shouldIncludeFile).map(getChapterIndexFromFile);
350+
const localChapters = (await fs.readdir(mangaDir)).filter(shouldIncludeFile);
351+
const localChapterIndexList = localChapters.map(getChapterIndexFromFile);
352352

353353
const remoteChapters = await getChaptersFromRemote(source, title);
354-
return remoteChapters.filter((c) => !localChapters.includes(c));
354+
const remoteChapterIndexList = remoteChapters.map((r) => r.index);
355+
356+
return remoteChapterIndexList.filter((c) => !localChapterIndexList.includes(c));
355357
};
356358

357359
export const createLibrary = async (libraryPath: string) => {
@@ -361,3 +363,13 @@ export const createLibrary = async (libraryPath: string) => {
361363
export const removeManga = async (mangaDir: string) => {
362364
await fs.rm(mangaDir, { recursive: true, force: true });
363365
};
366+
367+
export const getOutOfSyncChapters = async (mangaDir: string, source: string, title: string) => {
368+
const localChapterNames = (await fs.readdir(mangaDir)).filter(shouldIncludeFile);
369+
const remoteChapters = await getChaptersFromRemote(source, title);
370+
const remoteChapterNames = remoteChapters.map(
371+
(r) => `[${String(r.index + 1).padStart(4, '0')}]_${sanitizer(r.name)}.cbz`,
372+
);
373+
374+
return localChapterNames.filter((l) => !remoteChapterNames.includes(l));
375+
};

0 commit comments

Comments
 (0)