diff --git a/docker/Dockerfile b/docker/Dockerfile index 149e69a..7394ce7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -55,13 +55,13 @@ WORKDIR /tmp RUN \ if [ "$(uname -m)" = "x86_64" ] ; then \ - curl -L "https://github.com/metafates/mangal/releases/download/v3.14.2/mangal_3.14.2_Linux_x86_64.tar.gz" -o mangal.tar.gz ;\ + curl -L "https://github.com/metafates/mangal/releases/download/v4.0.0/mangal_4.0.0_Linux_x86_64.tar.gz" -o mangal.tar.gz ;\ elif [ "$(uname -m)" = "armv6l" ] ; then \ - curl -L "https://github.com/metafates/mangal/releases/download/v3.14.2/mangal_3.14.2_Linux_armv6.tar.gz" -o mangal.tar.gz ;\ + curl -L "https://github.com/metafates/mangal/releases/download/v4.0.0/mangal_4.0.0_Linux_armv6.tar.gz" -o mangal.tar.gz ;\ elif [ "$(uname -m)" = "i386" ] ; then \ - curl -L "https://github.com/metafates/mangal/releases/download/v3.14.2/mangal_3.14.2_Linux_i386.tar.gz" -o mangal.tar.gz ;\ + curl -L "https://github.com/metafates/mangal/releases/download/v4.0.0/mangal_4.0.0_Linux_i386.tar.gz" -o mangal.tar.gz ;\ elif [ "$(uname -m)" = "aarch64" ] ; then \ - curl -L "https://github.com/metafates/mangal/releases/download/v3.14.2/mangal_3.14.2_Linux_arm64.tar.gz" -o mangal.tar.gz ;\ + curl -L "https://github.com/metafates/mangal/releases/download/v4.0.0/mangal_4.0.0_Linux_arm64.tar.gz" -o mangal.tar.gz ;\ fi RUN tar xf mangal.tar.gz RUN mv mangal /usr/bin/mangal @@ -74,6 +74,8 @@ ENV NEXT_TELEMETRY_DISABLED 1 ENV HOME="/config" ENV KAIZOKU_LOG_PATH="/logs" ENV MANGAL_METADATA_COMIC_INFO_XML=true +ENV MANGAL_METADATA_COMIC_INFO_XML_ADD_DATE=true +ENV MANGAL_METADATA_COMIC_INFO_XML_ALTERNATIVE_DATE=true ENV MANGAL_METADATA_FETCH_ANILIST=true ENV MANGAL_METADATA_SERIES_JSON=true ENV MANGAL_FORMATS_USE=cbz diff --git a/src/components/addLibrary.tsx b/src/components/addLibrary.tsx index 882b94f..11709d1 100644 --- a/src/components/addLibrary.tsx +++ b/src/components/addLibrary.tsx @@ -68,7 +68,7 @@ function Form({ onClose }: { onClose: () => void }) { })} > - + ({ diff --git a/src/components/addManga/steps/reviewStep.tsx b/src/components/addManga/steps/reviewStep.tsx index 47fe606..5cd347e 100644 --- a/src/components/addManga/steps/reviewStep.tsx +++ b/src/components/addManga/steps/reviewStep.tsx @@ -48,12 +48,12 @@ export function ReviewStep({ form }: { form: UseFormReturnType }) { const handleBind = async () => { setOpened(false); - if (!anilistId || !manga?.Name) { + if (!anilistId || !manga?.name) { return; } await bindMutation.mutateAsync({ anilistId, - title: manga.Name, + title: manga.name, }); query.refetch(); @@ -82,10 +82,10 @@ export function ReviewStep({ form }: { form: UseFormReturnType }) { boxShadow: theme.shadows.xl, })} src="/cover-not-found.jpg" - alt={manga.Name} + alt={manga.name} /> } - src={manga.Metadata.Cover} + src={manga.metadata.cover.extraLarge || manga.metadata.cover.large || manga.metadata.cover.medium} /> @@ -94,7 +94,7 @@ export function ReviewStep({ form }: { form: UseFormReturnType }) { labelPosition="center" label={ <> - {manga.Name} + {manga.name} }) { rightSectionWidth={42} label={ - Please enter a new AniList id for {manga.Name} + Please enter a new AniList id for {manga.name} } placeholder="AniList Id" @@ -150,9 +150,9 @@ export function ReviewStep({ form }: { form: UseFormReturnType }) { } /> - {manga.Metadata.Synonyms && ( + {manga.metadata.synonyms && ( - {manga.Metadata.Synonyms.map((synonym) => ( + {manga.metadata.synonyms.map((synonym) => (
@@ -164,9 +164,9 @@ export function ReviewStep({ form }: { form: UseFormReturnType }) { )} - {manga.Metadata.Status ? ( + {manga.metadata.status ? ( - {manga.Metadata.Status} + {manga.metadata.status} ) : ( No status... @@ -175,16 +175,16 @@ export function ReviewStep({ form }: { form: UseFormReturnType }) { There are   - {manga.Chapters?.length || 0} + {manga.chapters?.length || 0}   chapters - {manga.Metadata.Summary || 'No summary...'} + {manga.metadata.summary || 'No summary...'} - {manga.Metadata.Genres ? ( + {manga.metadata.genres ? ( - {manga.Metadata.Genres.map((genre) => ( + {manga.metadata.genres.map((genre) => (
@@ -198,9 +198,9 @@ export function ReviewStep({ form }: { form: UseFormReturnType }) { No genres... )} - {manga.Metadata.Tags ? ( + {manga.metadata.tags ? ( - {manga.Metadata.Tags.map((tag) => ( + {manga.metadata.tags.map((tag) => (
diff --git a/src/server/index.ts b/src/server/index.ts index 3d617c4..464e5a9 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -7,6 +7,7 @@ import { logger } from '../utils/logging'; import { checkChaptersQueue, scheduleAll } from './queue/checkChapters'; import { downloadQueue } from './queue/download'; import { notificationQueue } from './queue/notify'; +import { updateMetadataQueue } from './queue/updateMetadata'; const dev = process.env.NODE_ENV !== 'production'; const app = next({ dev }); @@ -17,7 +18,12 @@ const serverAdapter = new ExpressAdapter(); serverAdapter.setBasePath('/bull/queues'); createBullBoard({ - queues: [new BullAdapter(downloadQueue), new BullAdapter(checkChaptersQueue), new BullAdapter(notificationQueue)], + queues: [ + new BullAdapter(downloadQueue), + new BullAdapter(checkChaptersQueue), + new BullAdapter(notificationQueue), + new BullAdapter(updateMetadataQueue), + ], serverAdapter, }); diff --git a/src/server/queue/updateMetadata.ts b/src/server/queue/updateMetadata.ts new file mode 100644 index 0000000..b12fa72 --- /dev/null +++ b/src/server/queue/updateMetadata.ts @@ -0,0 +1,57 @@ +import { Job, Queue, Worker } from 'bullmq'; + +import { getMangaPath, updateExistingMangaMetadata } from '../utils/mangal'; + +export interface IUpdateMetadataWorkerData { + libraryPath: string; + mangaTitle: string; +} + +export const updateMetadataWorker = new Worker( + 'updateMetadataQueue', + async (job: Job) => { + const { libraryPath, mangaTitle }: IUpdateMetadataWorkerData = job.data; + try { + await updateExistingMangaMetadata(libraryPath, mangaTitle); + await job.updateProgress(100); + } catch (err) { + await job.log(`${err}`); + throw err; + } + }, + { + connection: { + host: process.env.REDIS_HOST, + port: parseInt(process.env.REDIS_PORT || '6379', 10), + }, + concurrency: 5, + }, +); + +export const updateMetadataQueue = new Queue('updateMetadataQueue', { + connection: { + host: process.env.REDIS_HOST, + port: parseInt(process.env.REDIS_PORT || '6379', 10), + }, + defaultJobOptions: { + removeOnComplete: true, + attempts: 10, + backoff: { + type: 'fixed', + delay: 1000 * 60 * 2, + }, + }, +}); + +export const scheduleUpdateMetadata = async (libraryPath: string, mangaTitle: string) => { + await updateMetadataQueue.add( + getMangaPath(libraryPath, mangaTitle), + { + libraryPath, + mangaTitle, + }, + { + jobId: getMangaPath(libraryPath, mangaTitle), + }, + ); +}; diff --git a/src/server/trpc/router/manga.ts b/src/server/trpc/router/manga.ts index e5c128f..753c37e 100644 --- a/src/server/trpc/router/manga.ts +++ b/src/server/trpc/router/manga.ts @@ -4,14 +4,8 @@ import { z } from 'zod'; import { isCronValid, sanitizer } from '../../../utils'; import { checkChaptersQueue, removeJob, schedule } from '../../queue/checkChapters'; import { downloadQueue, downloadWorker, removeDownloadJobs } from '../../queue/download'; -import { - bindTitleToAnilistId, - getAvailableSources, - getMangaDetail, - Manga, - removeManga, - search, -} from '../../utils/mangal'; +import { scheduleUpdateMetadata } from '../../queue/updateMetadata'; +import { bindTitleToAnilistId, getAvailableSources, getMangaDetail, removeManga, search } from '../../utils/mangal'; import { t } from '../trpc'; export const mangaRouter = t.router({ @@ -73,12 +67,15 @@ export const mangaRouter = t.router({ ) .query(async ({ input }) => { const { keyword, source } = input; - const result = await search(source, keyword); - return result.Manga.map((m) => ({ - status: m.Metadata.Status, - title: m.Name, - cover: m.Metadata.Cover, - })).filter((m) => !!m.title); + const { result } = await search(source, keyword); + return result + .map((m) => ({ + status: m.mangal.metadata.status, + title: m.mangal.name, + cover: + m.mangal.metadata.cover?.extraLarge || m.mangal.metadata.cover?.large || m.mangal.metadata.cover?.medium, + })) + .filter((m) => !!m.title); }), remove: t.procedure .input( @@ -128,7 +125,7 @@ export const mangaRouter = t.router({ ) .mutation(async ({ input, ctx }) => { const { source, title, interval } = input; - const mangaDetail: Manga | undefined = await getMangaDetail(source, title); + const mangaDetail = await getMangaDetail(source, title); const library = await ctx.prisma.library.findFirst(); if (!mangaDetail || !library) { throw new TRPCError({ @@ -148,7 +145,7 @@ export const mangaRouter = t.router({ }); } - if (mangaDetail.Name !== title) { + if (mangaDetail.name !== title) { throw new TRPCError({ code: 'NOT_FOUND', message: `${title} does not match the found manga.`, @@ -162,7 +159,7 @@ export const mangaRouter = t.router({ }, data: { source, - title: mangaDetail.Name, + title: mangaDetail.name, library: { connect: { id: library.id, @@ -171,29 +168,32 @@ export const mangaRouter = t.router({ interval, metadata: { create: { - cover: mangaDetail.Metadata.Cover, - authors: mangaDetail.Metadata.Author ? [mangaDetail.Metadata.Author] : [], - characters: mangaDetail.Metadata.Characters, - genres: mangaDetail.Metadata.Genres, - startDate: mangaDetail.Metadata.StartDate + cover: + mangaDetail.metadata.cover?.extraLarge || + mangaDetail.metadata.cover?.large || + mangaDetail.metadata.cover?.medium, + authors: mangaDetail.metadata.staff?.story ? [...mangaDetail.metadata.staff.story] : [], + characters: mangaDetail.metadata.characters, + genres: mangaDetail.metadata.genres, + startDate: mangaDetail.metadata.startDate ? new Date( - mangaDetail.Metadata.StartDate.Year, - mangaDetail.Metadata.StartDate.Month, - mangaDetail.Metadata.StartDate.Day, + mangaDetail.metadata.startDate.year, + mangaDetail.metadata.startDate.month, + mangaDetail.metadata.startDate.day, ) : undefined, - endDate: mangaDetail.Metadata.EndDate + endDate: mangaDetail.metadata.endDate ? new Date( - mangaDetail.Metadata.EndDate.Year, - mangaDetail.Metadata.EndDate.Month, - mangaDetail.Metadata.EndDate.Day, + mangaDetail.metadata.endDate.year, + mangaDetail.metadata.endDate.month, + mangaDetail.metadata.endDate.day, ) : undefined, - status: mangaDetail.Metadata.Status, - summary: mangaDetail.Metadata.Summary, - synonyms: mangaDetail.Metadata.Synonyms, - tags: mangaDetail.Metadata.Tags, - urls: mangaDetail.Metadata.URLs, + status: mangaDetail.metadata.status, + summary: mangaDetail.metadata.summary, + synonyms: mangaDetail.metadata.synonyms, + tags: mangaDetail.metadata.tags, + urls: mangaDetail.metadata.urls, }, }, }, @@ -220,14 +220,18 @@ export const mangaRouter = t.router({ .mutation(async ({ input, ctx }) => { const { id, interval, anilistId } = input; const mangaInDb = await ctx.prisma.manga.findUniqueOrThrow({ + include: { + library: true, + }, where: { id }, }); if (anilistId) { await bindTitleToAnilistId(mangaInDb.title, anilistId); + await scheduleUpdateMetadata(mangaInDb.library.path, mangaInDb.title); } - const mangaDetail: Manga | undefined = await getMangaDetail(mangaInDb.source, mangaInDb.title); + const mangaDetail = await getMangaDetail(mangaInDb.source, mangaInDb.title); if (!mangaDetail) { throw new TRPCError({ code: 'NOT_FOUND', @@ -240,29 +244,32 @@ export const mangaRouter = t.router({ id: mangaInDb.metadataId, }, data: { - cover: mangaDetail.Metadata.Cover, - authors: mangaDetail.Metadata.Author ? [mangaDetail.Metadata.Author] : [], - characters: mangaDetail.Metadata.Characters, - genres: mangaDetail.Metadata.Genres, - startDate: mangaDetail.Metadata.StartDate + cover: + mangaDetail.metadata.cover?.extraLarge || + mangaDetail.metadata.cover?.large || + mangaDetail.metadata.cover?.medium, + authors: mangaDetail.metadata.staff?.story ? [...mangaDetail.metadata.staff.story] : [], + characters: mangaDetail.metadata.characters, + genres: mangaDetail.metadata.genres, + startDate: mangaDetail.metadata.startDate ? new Date( - mangaDetail.Metadata.StartDate.Year, - mangaDetail.Metadata.StartDate.Month, - mangaDetail.Metadata.StartDate.Day, + mangaDetail.metadata.startDate.year, + mangaDetail.metadata.startDate.month, + mangaDetail.metadata.startDate.day, ) : undefined, - endDate: mangaDetail.Metadata.EndDate + endDate: mangaDetail.metadata.endDate ? new Date( - mangaDetail.Metadata.EndDate.Year, - mangaDetail.Metadata.EndDate.Month, - mangaDetail.Metadata.EndDate.Day, + mangaDetail.metadata.endDate.year, + mangaDetail.metadata.endDate.month, + mangaDetail.metadata.endDate.day, ) : undefined, - status: mangaDetail.Metadata.Status, - summary: mangaDetail.Metadata.Summary, - synonyms: mangaDetail.Metadata.Synonyms, - tags: mangaDetail.Metadata.Tags, - urls: mangaDetail.Metadata.URLs, + status: mangaDetail.metadata.status, + summary: mangaDetail.metadata.summary, + synonyms: mangaDetail.metadata.synonyms, + tags: mangaDetail.metadata.tags, + urls: mangaDetail.metadata.urls, }, }); diff --git a/src/server/utils/mangal.ts b/src/server/utils/mangal.ts index 8e5ba03..5605fa6 100644 --- a/src/server/utils/mangal.ts +++ b/src/server/utils/mangal.ts @@ -5,44 +5,65 @@ import { logger } from '../../utils/logging'; import { sanitizer } from '../../utils'; interface IOutput { - Manga: Manga[]; + result: Result[]; } -export interface Manga { - Name: string; - URL: string; - Index: number; - ID: string; - Chapters: Chapter[]; - Metadata: Metadata; +interface Result { + source: string; + mangal: Mangal; +} + +interface Mangal { + name: string; + url: string; + index: number; + id: string; + chapters: Chapter[]; + metadata: Metadata; } interface Chapter { - Name: string; - URL: string; - Index: number; - ID: string; - Volume: string; + name: string; + url: string; + index: number; + id: string; + volume: string; } interface Metadata { - Genres: string[]; - Summary: string; - Author: string; - Cover: string; - Tags: string[]; - Characters: string[]; - Status: string; - StartDate: MangaDate; - EndDate: MangaDate; - Synonyms: string[]; - URLs: string[]; + genres: string[]; + summary: string; + staff: Staff; + cover: Cover; + bannerImage: string; + tags: string[]; + characters: string[]; + status: string; + startDate: MangaDate; + endDate: MangaDate; + synonyms: string[]; + chapters: number; + urls: string[]; +} + +interface Cover { + extraLarge: string; + large: string; + medium: string; + color: string; } interface MangaDate { - Year: number; - Month: number; - Day: number; + year: number; + month: number; + day: number; +} + +interface Staff { + story: string[]; + art: string[]; + translation: string[]; + lettering: string[]; } interface ChapterFile { @@ -51,9 +72,11 @@ interface ChapterFile { fileName: string; } +export const getMangaPath = (libraryPath: string, title: string) => path.resolve(libraryPath, sanitizer(title)); + export const getAvailableSources = async () => { try { - const { stdout, command } = await execa('mangal', ['sources', '-r']); + const { stdout, command } = await execa('mangal', ['sources', 'list', '-r']); logger.info(`Get available sources with following command: ${command}`); return stdout .split('\n') @@ -75,7 +98,20 @@ export const bindTitleToAnilistId = async (title: string, anilistId: string) => } }; -export const getMangaPath = (libraryPath: string, title: string) => path.resolve(libraryPath, sanitizer(title)); +export const updateExistingMangaMetadata = async (libraryPath: string, title: string) => { + try { + const { command } = await execa('mangal', [ + 'inline', + 'anilist', + 'update', + '--path', + getMangaPath(libraryPath, title), + ]); + logger.info(`Updated existing manga metadata: ${command}`); + } catch (err) { + logger.error(`Failed to update existing manga metadata. err: ${err}`); + } +}; export const search = async (source: string, keyword: string): Promise => { try { @@ -87,7 +123,7 @@ export const search = async (source: string, keyword: string): Promise } return { - Manga: [], + result: [], }; }; @@ -106,9 +142,14 @@ export const getChaptersFromRemote = async (source: string, title: string): Prom '-j', ]); logger.info(`Get chapters with following command: ${command}`); - const result: IOutput = JSON.parse(stdout); - if (result && result.Manga.length === 1 && result.Manga[0]?.Chapters && result.Manga[0]?.Chapters.length > 0) { - return result.Manga[0].Chapters.map((c) => c.Index - 1); + const output: IOutput = JSON.parse(stdout); + if ( + output && + output.result.length === 1 && + output.result[0]?.mangal.chapters && + output.result[0]?.mangal.chapters.length > 0 + ) { + return output.result[0].mangal.chapters.map((c) => c.index - 1); } } catch (err) { logger.error(err); @@ -117,7 +158,7 @@ export const getChaptersFromRemote = async (source: string, title: string): Prom return []; }; -export const getMangaDetail = async (source: string, title: string): Promise => { +export const getMangaDetail = async (source: string, title: string): Promise => { try { const { stdout, command } = await execa('mangal', [ 'inline', @@ -127,12 +168,14 @@ export const getMangaDetail = async (source: string, title: string): Promise