diff --git a/src/components/mangaCard.tsx b/src/components/mangaCard.tsx
index 537c728..e0870cc 100644
--- a/src/components/mangaCard.tsx
+++ b/src/components/mangaCard.tsx
@@ -1,23 +1,10 @@
-import {
- ActionIcon,
- Alert,
- Badge,
- Box,
- Button,
- Checkbox,
- Code,
- createStyles,
- Paper,
- Skeleton,
- Text,
- Title,
-} from '@mantine/core';
-import { useModals } from '@mantine/modals';
+import { ActionIcon, Badge, createStyles, Paper, Skeleton, Title, Tooltip } from '@mantine/core';
import { Prisma } from '@prisma/client';
-import { IconEdit, IconX } from '@tabler/icons';
+import { IconEdit, IconRefresh, IconX } from '@tabler/icons';
import { contrastColor } from 'contrast-color';
-import { useState } from 'react';
import stc from 'string-to-color';
+import { useRefreshModal } from './refreshMetadata';
+import { useRemoveModal } from './removeManga';
import { useUpdateModal } from './updateManga';
const useStyles = createStyles((theme, _params, getRef) => ({
@@ -49,6 +36,9 @@ const useStyles = createStyles((theme, _params, getRef) => ({
[`&:hover .${getRef('editButton')}`]: {
display: 'flex',
},
+ [`&:hover .${getRef('refreshButton')}`]: {
+ display: 'flex',
+ },
},
removeButton: {
ref: getRef('removeButton'),
@@ -57,6 +47,18 @@ const useStyles = createStyles((theme, _params, getRef) => ({
top: -5,
display: 'none',
},
+ refreshButton: {
+ ref: getRef('refreshButton'),
+ backgroundColor: theme.white,
+ color: theme.colors.blue[6],
+ position: 'absolute',
+ right: 10,
+ bottom: 50,
+ display: 'none',
+ '&:hover': {
+ backgroundColor: theme.colors.gray[0],
+ },
+ },
editButton: {
ref: getRef('editButton'),
backgroundColor: theme.white,
@@ -93,92 +95,21 @@ interface MangaCardProps {
manga: MangaWithLibraryAndMetadata;
onRemove: (shouldRemoveFiles: boolean) => void;
onUpdate: () => void;
+ onRefresh: () => void;
onClick: () => void;
}
-function RemoveModalContent({
- title,
- onRemove,
- onClose,
-}: {
- title: string;
- onRemove: (shouldRemoveFiles: boolean) => void;
- onClose: () => void;
-}) {
- const [shouldRemoveFiles, setShouldRemoveFiles] = useState(false);
- return (
- <>
-
- Are you sure you want to remove
-
- {title}
-
- ?
-
- setShouldRemoveFiles(event.currentTarget.checked)}
- />
- }
- title="Remove files?"
- color="red"
- >
- This action is destructive and all downloaded files will be removed
-
- ({
- display: 'flex',
- gap: theme.spacing.xs,
- justifyContent: 'end',
- marginTop: theme.spacing.md,
- })}
- >
-
-
-
- >
- );
-}
-
-const useRemoveModal = (title: string, onRemove: (shouldRemoveFiles: boolean) => void) => {
- const modals = useModals();
-
- const openRemoveModal = () => {
- const id = modals.openModal({
- title: `Remove ${title}?`,
- centered: true,
- children: modals.closeModal(id)} />,
- });
- };
-
- return openRemoveModal;
-};
-
export function SkeletonMangaCard() {
const { classes } = useStyles();
return ;
}
-export function MangaCard({ manga, onRemove, onUpdate, onClick }: MangaCardProps) {
+export function MangaCard({ manga, onRemove, onUpdate, onRefresh, onClick }: MangaCardProps) {
const { classes } = useStyles();
const removeModal = useRemoveModal(manga.title, onRemove);
+ const refreshModal = useRefreshModal(manga.title, onRefresh);
const updateModal = useUpdateModal(manga, onUpdate);
-
return (
- ) => {
- e.stopPropagation();
- updateModal();
- }}
- >
-
-
+
+ ) => {
+ e.stopPropagation();
+ refreshModal();
+ }}
+ >
+
+
+
+
+ ) => {
+ e.stopPropagation();
+ updateModal();
+ }}
+ >
+
+
+
void; onClose: () => void }) {
+ return (
+ <>
+
+ This will update all downloaded chapters with the latest metadata from AniList
+
+ ({
+ display: 'flex',
+ gap: theme.spacing.xs,
+ justifyContent: 'end',
+ marginTop: theme.spacing.md,
+ })}
+ >
+
+
+
+ >
+ );
+}
+
+export const useRefreshModal = (title: string, onRefresh: () => void) => {
+ const modals = useModals();
+
+ const openRemoveModal = () => {
+ const id = modals.openModal({
+ title: `Refresh Metadata for ${title}?`,
+ centered: true,
+ children: modals.closeModal(id)} />,
+ });
+ };
+
+ return openRemoveModal;
+};
diff --git a/src/components/removeManga.tsx b/src/components/removeManga.tsx
new file mode 100644
index 0000000..39107d6
--- /dev/null
+++ b/src/components/removeManga.tsx
@@ -0,0 +1,75 @@
+import { Alert, Box, Button, Checkbox, Code, Text } from '@mantine/core';
+import { useModals } from '@mantine/modals';
+import { useState } from 'react';
+
+function RemoveModalContent({
+ title,
+ onRemove,
+ onClose,
+}: {
+ title: string;
+ onRemove: (shouldRemoveFiles: boolean) => void;
+ onClose: () => void;
+}) {
+ const [shouldRemoveFiles, setShouldRemoveFiles] = useState(false);
+ return (
+ <>
+
+ Are you sure you want to remove
+
+ {title}
+
+ ?
+
+ setShouldRemoveFiles(event.currentTarget.checked)}
+ />
+ }
+ title="Remove files?"
+ color="red"
+ >
+ This action is destructive and all downloaded files will be removed
+
+ ({
+ display: 'flex',
+ gap: theme.spacing.xs,
+ justifyContent: 'end',
+ marginTop: theme.spacing.md,
+ })}
+ >
+
+
+
+ >
+ );
+}
+
+export const useRemoveModal = (title: string, onRemove: (shouldRemoveFiles: boolean) => void) => {
+ const modals = useModals();
+
+ const openRemoveModal = () => {
+ const id = modals.openModal({
+ title: `Remove ${title}?`,
+ centered: true,
+ children: modals.closeModal(id)} />,
+ });
+ };
+
+ return openRemoveModal;
+};
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index 4f5fa3a..9f28b17 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -11,6 +11,7 @@ import { trpc } from '../utils/trpc';
export default function IndexPage() {
const libraryQuery = trpc.library.query.useQuery();
const mangaRemove = trpc.manga.remove.useMutation();
+ const mangaRefresh = trpc.manga.refreshMetaData.useMutation();
const router = useRouter();
const mangaQuery = trpc.manga.query.useQuery();
@@ -79,6 +80,38 @@ export default function IndexPage() {
mangaQuery.refetch();
};
+ const handleRefresh = async (id: number, title: string) => {
+ try {
+ await mangaRefresh.mutateAsync({
+ id,
+ });
+ showNotification({
+ icon: ,
+ color: 'teal',
+ autoClose: true,
+ title: 'Manga',
+ message: (
+
+ {title}
chapters are queued for the metadata update
+
+ ),
+ });
+ } catch (err) {
+ showNotification({
+ icon: ,
+ color: 'red',
+ autoClose: true,
+ title: 'Manga',
+ message: (
+
+ {`${err}`}
+
+ ),
+ });
+ }
+ mangaQuery.refetch();
+ };
+
return (
@@ -91,6 +124,7 @@ export default function IndexPage() {
handleRefresh(manga.id, manga.title)}
onUpdate={() => mangaQuery.refetch()}
onRemove={(shouldRemoveFiles: boolean) => handleRemove(manga.id, manga.title, shouldRemoveFiles)}
onClick={() => router.push(`/manga/${manga.id}`)}
diff --git a/src/server/trpc/router/manga.ts b/src/server/trpc/router/manga.ts
index c84b72b..5d2bb91 100644
--- a/src/server/trpc/router/manga.ts
+++ b/src/server/trpc/router/manga.ts
@@ -6,7 +6,14 @@ import { checkChaptersQueue, removeJob, schedule } from '../../queue/checkChapte
import { downloadQueue, downloadWorker, removeDownloadJobs } from '../../queue/download';
import { scheduleUpdateMetadata } from '../../queue/updateMetadata';
import { scanLibrary } from '../../utils/integration';
-import { bindTitleToAnilistId, getAvailableSources, getMangaDetail, removeManga, search } from '../../utils/mangal';
+import {
+ bindTitleToAnilistId,
+ getAvailableSources,
+ getMangaDetail,
+ getMangaMetadata,
+ removeManga,
+ search,
+} from '../../utils/mangal';
import { t } from '../trpc';
export const mangaRouter = t.router({
@@ -312,4 +319,49 @@ export const mangaRouter = t.router({
completed: await downloadQueue.getCompletedCount(),
};
}),
+ refreshMetaData: t.procedure
+ .input(
+ z.object({
+ id: z.number(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const { id } = input;
+ const mangaInDb = await ctx.prisma.manga.findUniqueOrThrow({
+ include: { library: true },
+ where: { id },
+ });
+ const metadata = await getMangaMetadata(mangaInDb.source, mangaInDb.title);
+ if (!metadata) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: `Cannot find the metadata for ${mangaInDb.title}.`,
+ });
+ }
+ await ctx.prisma.metadata.update({
+ where: {
+ id: mangaInDb.metadataId,
+ },
+ data: {
+ cover: metadata.cover?.extraLarge || metadata.cover?.large || metadata.cover?.medium,
+ authors: metadata.staff?.story ? [...metadata.staff.story] : [],
+ characters: metadata.characters,
+ genres: metadata.genres,
+ startDate: metadata.startDate
+ ? new Date(metadata.startDate.year, metadata.startDate.month, metadata.startDate.day)
+ : undefined,
+ endDate: metadata.endDate
+ ? new Date(metadata.endDate.year, metadata.endDate.month, metadata.endDate.day)
+ : undefined,
+ status: metadata.status,
+ summary: metadata.summary,
+ synonyms: metadata.synonyms,
+ tags: metadata.tags,
+ urls: metadata.urls,
+ },
+ });
+ await scheduleUpdateMetadata(mangaInDb.library.path, mangaInDb.title);
+
+ return ctx.prisma.manga.findUniqueOrThrow({ include: { metadata: true, library: true }, where: { id } });
+ }),
});
diff --git a/src/server/utils/mangal.ts b/src/server/utils/mangal.ts
index df87dcb..79d8394 100644
--- a/src/server/utils/mangal.ts
+++ b/src/server/utils/mangal.ts
@@ -219,6 +219,31 @@ export const getChaptersFromRemote = async (source: string, title: string): Prom
return [];
};
+export const getMangaMetadata = async (source: string, title: string): Promise => {
+ try {
+ const { stdout, escapedCommand } = await execa('mangal', [
+ 'inline',
+ '--source',
+ source,
+ '--include-anilist-manga',
+ '--query',
+ title,
+ '--manga',
+ 'exact',
+ '-j',
+ ]);
+ logger.info(`Get manga metadata with following command: ${escapedCommand}`);
+ const output: IOutput = JSON.parse(stdout);
+ if (output && output.result.length === 1) {
+ return output.result[0].mangal?.metadata;
+ }
+ } catch (err) {
+ logger.error(err);
+ }
+
+ return undefined;
+};
+
export const getMangaDetail = async (source: string, title: string): Promise => {
try {
const { stdout, escapedCommand } = await execa('mangal', [