From c2c03e76ee1f932bd0745acc3dad5b4be5774f95 Mon Sep 17 00:00:00 2001 From: Tachibana Shin Date: Thu, 10 Aug 2023 17:20:10 +0000 Subject: [PATCH] upgrade idm --- .../download-manager.spec.ts.snap | 19 + src/logic/download-manager.spec.ts | 641 ++++++++++-------- src/logic/download-manager.ts | 297 ++++++-- src/stores/IDM.ts | 40 +- test/vitest/utils.ts | 6 + vitest.config.ts | 2 +- 6 files changed, 653 insertions(+), 352 deletions(-) diff --git a/src/logic/__snapshots__/download-manager.spec.ts.snap b/src/logic/__snapshots__/download-manager.spec.ts.snap index 4559f25e..8bd2b098 100644 --- a/src/logic/__snapshots__/download-manager.spec.ts.snap +++ b/src/logic/__snapshots__/download-manager.spec.ts.snap @@ -1,3 +1,22 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`download-manager > should download episode x for the first time 1`] = ` +{ + "downloaded": 8, + "ep_id": 1234, + "ep_name": "Chapter 1", + "pages": [ + "offline://files/1a96284b/30089c30/1a96284a", + "offline://files/1a96284b/30089c30/1a96284b", + "offline://files/1a96284b/30089c30/1a96284c", + "offline://files/1a96284b/30089c30/1a96284d", + "offline://files/1a96284b/30089c30/1a96284e", + "offline://files/1a96284b/30089c30/1a96284f", + "offline://files/1a96284b/30089c30/1a962850", + "offline://files/1a96284b/30089c30/1a962851", + ], + "start_download_at": 1690022500169, +} +`; + exports[`download-manager > should forcibly stopped while downloading 1`] = `"time_out"`; diff --git a/src/logic/download-manager.spec.ts b/src/logic/download-manager.spec.ts index 7670b711..ccebe6fd 100644 --- a/src/logic/download-manager.spec.ts +++ b/src/logic/download-manager.spec.ts @@ -1,14 +1,15 @@ /* eslint-disable camelcase */ import hashSum from "hash-sum" -import { cleanup, readdir, readFile } from "test/vitest/utils" +import { cleanup, exists, readdir, readFile } from "test/vitest/utils" import { expect } from "vitest" -import { createTaskDownloadEpisode, getListEpisodes } from "./download-manager" +import type { MetaEpisode, MetaManga } from "./download-manager" +import { createTaskDownloadEpisode, getListManga } from "./download-manager" global.fetch = vi.fn() global.Date.now = vi.fn() -const path = "/manga-1/chap-1" +const path = "/manga-1" const manga_id = 1 const manga_name = "Manga 1" const manga_image = "http://localhost/poster/manga-1.jpg" @@ -24,42 +25,76 @@ const pages = [ "https://localhost/pages/7.png", "https://localhost/pages/8.png", ] - -const meta = { path, manga_id, manga_name, manga_image, ep_id, ep_name, pages } +const pathEp = path + "/chap-1" + +const metaManga: MetaManga = { + path, + manga_id, + manga_image, + manga_name, +} +const metaEp: MetaEpisode = { + path: pathEp, + ep_id, + ep_name, + pages, +} + +const hashIDManga = hashSum(manga_id) +const hashIDEp = hashSum(ep_id) + +function patchFetch() { + // continue download + ;(fetch as ReturnType).mockReset() + ;(fetch as ReturnType).mockImplementation(async (url) => { + await sleep(100) + return Promise.resolve({ + async arrayBuffer() { + return new TextEncoder().encode(url) + }, + async text() { + return url + }, + }) + }) +} describe("download-manager", () => { beforeEach(cleanup) ;(Date.now as ReturnType).mockReturnValue(1690022500169) test("should download episode x for the first time", async () => { - ;(fetch as ReturnType).mockImplementation(async (url) => { - // await sleep(500) + patchFetch() - return Promise.resolve({ - async arrayBuffer() { - return new TextEncoder().encode(url) - }, - async text() { - return url - }, - }) - }) - - const { ref, start } = createTaskDownloadEpisode(meta) + const { ref, start, downloading } = createTaskDownloadEpisode( + metaManga, + metaEp + ) expect(ref.value.downloaded).toBe(0) + expect(downloading.value).toBeFalsy() const watcher = vi.fn() watch(ref, watcher, { deep: true }) await start() - const hash_id = hashSum(`${manga_id}ɣ${ep_id}`) // check directory - expect(await readdir("")).toEqual(["files", "meta", "poster"]) - expect(await readdir("files")).toEqual([hash_id]) + await expect(readdir("")).resolves.toEqual(["files", "meta", "poster"]) - // check hash file page - expect(await readdir(`files/${hash_id}`)).toEqual([ + await expect(readdir("files")).resolves.toEqual([hashIDManga]) + await expect(readdir("files/" + hashIDManga)).resolves.toEqual([hashIDEp]) + + await expect(readdir("meta")).resolves.toEqual([ + hashIDManga, + hashIDManga + ".mod", + ]) + await expect(readdir("meta/" + hashIDManga)).resolves.toEqual([ + hashIDEp + ".mod", + ]) + + await expect(readdir("poster")).resolves.toEqual([hashIDManga]) + + expect(await readdir(`files/${hashIDManga}/${hashIDEp}`)).toEqual([ "1a96284a", "1a96284b", "1a96284c", @@ -70,37 +105,50 @@ describe("download-manager", () => { "1a962851", ]) - expect(await readdir("meta")).toEqual([hash_id]) - expect(await readdir("poster")).toEqual([hash_id]) - // valid image pages for (const index in pages) { - const path = `files/${hash_id}/${hashSum(+index)}` + const path = `files/${hashIDManga}/${hashIDEp}/${hashSum(+index)}` expect(await readFile(path, Encoding.UTF8)).toBe(pages[index]) } - // valid meta + // valid meta manga expect( - JSON.parse(await readFile("meta/" + hash_id, Encoding.UTF8)) + JSON.parse(await readFile(`meta/${hashIDManga}.mod`, Encoding.UTF8)) ).toEqual({ - path: "/manga-1/chap-1", - manga_id: 1, - manga_name: "Manga 1", - ep_id: 1234, - manga_image: "offline://poster/" + hash_id, - manga_image_downloaded: true, - ep_name: "Chapter 1", - pages: pages.map( - (_, index) => `offline://files/${hash_id}/${hashSum(index)}` - ), - downloaded: pages.length, + ...metaManga, + manga_image: "offline:///poster/1a96284b", start_download_at: 1690022500169, }) + // valid meta episode + expect( + JSON.parse( + await readFile(`meta/${hashIDManga}/${hashIDEp}.mod`, Encoding.UTF8) + ) + ).toEqual({ + path: pathEp, + start_download_at: 1690022500169, + downloaded: 8, + ep_id, + ep_name, + pages: [ + "offline://files/1a96284b/30089c30/1a96284a", + "offline://files/1a96284b/30089c30/1a96284b", + "offline://files/1a96284b/30089c30/1a96284c", + "offline://files/1a96284b/30089c30/1a96284d", + "offline://files/1a96284b/30089c30/1a96284e", + "offline://files/1a96284b/30089c30/1a96284f", + "offline://files/1a96284b/30089c30/1a962850", + "offline://files/1a96284b/30089c30/1a962851", + ], + }) + // valid meta image - expect(await readFile("poster/" + hash_id, Encoding.UTF8)).toBe(manga_image) - expect(watcher.mock.calls.length).toBe(10) + expect(await readFile("poster/" + hashIDManga, Encoding.UTF8)).toBe( + manga_image + ) + expect(watcher.mock.calls.length).toBe(9) }) test("should forcibly stopped while downloading", async () => { @@ -119,17 +167,17 @@ describe("download-manager", () => { }) }) - await createTaskDownloadEpisode(meta) - .start() - .catch(() => null) + const { ref, start, downloading } = createTaskDownloadEpisode( + metaManga, + metaEp + ) + expect(ref.value.downloaded).toBe(0) + expect(downloading.value).toBeFalsy() - const hash_id = hashSum(`${manga_id}ɣ${ep_id}`) - // check directory - expect(await readdir("")).toEqual(["files", "meta", "poster"]) - expect(await readdir("files")).toEqual([hash_id]) + await start().catch(() => null) // check hash file page - expect(await readdir(`files/${hash_id}`)).toEqual([ + expect(await readdir(`files/${hashIDManga}/${hashIDEp}`)).toEqual([ "1a96284a", "1a96284b", "1a96284c", @@ -137,43 +185,38 @@ describe("download-manager", () => { "1a96284e", ]) - expect(await readdir("meta")).toEqual([hash_id]) - expect(await readdir("poster")).toEqual([hash_id]) - // valid image page for (const index in pages.slice(0, 5)) { - const path = `files/${hash_id}/${hashSum(+index)}` + const path = `files/${hashIDManga}/${hashIDEp}/${hashSum(+index)}` expect(await readFile(path, Encoding.UTF8)).toBe(pages[index]) } // valid meta expect( - JSON.parse(await readFile("meta/" + hash_id, Encoding.UTF8)) + JSON.parse( + await readFile( + "meta/" + hashIDManga + "/" + hashIDEp + ".mod", + Encoding.UTF8 + ) + ) ).toEqual({ - path: "/manga-1/chap-1", - manga_id: 1, - manga_name: "Manga 1", - manga_image: "offline://poster/33e5d30a", + path: pathEp, + start_download_at: 1690022500169, + downloaded: 5, ep_id, - ep_name: "Chapter 1", - manga_image_downloaded: true, + ep_name, pages: [ - "offline://files/33e5d30a/1a96284a", - "offline://files/33e5d30a/1a96284b", - "offline://files/33e5d30a/1a96284c", - "offline://files/33e5d30a/1a96284d", - "offline://files/33e5d30a/1a96284e", + "offline://files/1a96284b/30089c30/1a96284a", + "offline://files/1a96284b/30089c30/1a96284b", + "offline://files/1a96284b/30089c30/1a96284c", + "offline://files/1a96284b/30089c30/1a96284d", + "offline://files/1a96284b/30089c30/1a96284e", "https://localhost/pages/6.png", "https://localhost/pages/7.png", "https://localhost/pages/8.png", ], - downloaded: 5, - start_download_at: 1690022500169, }) - - // valid meta image - expect(await readFile("poster/" + hash_id, Encoding.UTF8)).toBe(manga_image) }) test("should continue while downloading", async () => { @@ -192,17 +235,17 @@ describe("download-manager", () => { }) }) - await createTaskDownloadEpisode(meta) - .start() - .catch(() => false) + const { ref, start, downloading } = createTaskDownloadEpisode( + metaManga, + metaEp + ) + expect(ref.value.downloaded).toBe(0) + expect(downloading.value).toBeFalsy() - const hash_id = hashSum(`${manga_id}ɣ${ep_id}`) - // check directory - expect(await readdir("")).toEqual(["files", "meta", "poster"]) - expect(await readdir("files")).toEqual([hash_id]) + await start().catch(() => null) // check hash file page - expect(await readdir(`files/${hash_id}`)).toEqual([ + expect(await readdir(`files/${hashIDManga}/${hashIDEp}`)).toEqual([ "1a96284a", "1a96284b", "1a96284c", @@ -210,62 +253,54 @@ describe("download-manager", () => { "1a96284e", ]) - expect(await readdir("meta")).toEqual([hash_id]) - expect(await readdir("poster")).toEqual([hash_id]) - // valid image page for (const index in pages.slice(0, 5)) { - const path = `files/${hash_id}/${hashSum(+index)}` + const path = `files/${hashIDManga}/${hashIDEp}/${hashSum(+index)}` expect(await readFile(path, Encoding.UTF8)).toBe(pages[index]) } // valid meta expect( - JSON.parse(await readFile("meta/" + hash_id, Encoding.UTF8)) + JSON.parse( + await readFile( + "meta/" + hashIDManga + "/" + hashIDEp + ".mod", + Encoding.UTF8 + ) + ) ).toEqual({ - path: "/manga-1/chap-1", - manga_id: 1, - manga_name: "Manga 1", - manga_image: "offline://poster/33e5d30a", - manga_image_downloaded: true, + path: pathEp, ep_id, - ep_name: "Chapter 1", + ep_name, + start_download_at: 1690022500169, + downloaded: 5, pages: [ - "offline://files/33e5d30a/1a96284a", - "offline://files/33e5d30a/1a96284b", - "offline://files/33e5d30a/1a96284c", - "offline://files/33e5d30a/1a96284d", - "offline://files/33e5d30a/1a96284e", + "offline://files/1a96284b/30089c30/1a96284a", + "offline://files/1a96284b/30089c30/1a96284b", + "offline://files/1a96284b/30089c30/1a96284c", + "offline://files/1a96284b/30089c30/1a96284d", + "offline://files/1a96284b/30089c30/1a96284e", "https://localhost/pages/6.png", "https://localhost/pages/7.png", "https://localhost/pages/8.png", ], - downloaded: 5, - start_download_at: 1690022500169, }) - // valid meta image - expect(await readFile("poster/" + hash_id, Encoding.UTF8)).toBe(manga_image) - - // continue download - ;(fetch as ReturnType).mockReset() - ;(fetch as ReturnType).mockImplementation(async (url) => { - return Promise.resolve({ - async arrayBuffer() { - return new TextEncoder().encode(url) - }, - async text() { - return url - }, - }) - }) + patchFetch() ;(Date.now as ReturnType).mockReturnValueOnce(1690022500170) - await createTaskDownloadEpisode(meta).start() + const { + ref: ref2, + start: start2, + downloading: dl2, + } = createTaskDownloadEpisode(metaManga, metaEp) + expect(ref2.value.downloaded).toBe(0) + expect(dl2.value).toBeFalsy() + + await start2().catch(() => null) // check hash file page - expect(await readdir(`files/${hash_id}`)).toEqual([ + expect(await readdir(`files/${hashIDManga}/${hashIDEp}`)).toEqual([ "1a96284a", "1a96284b", "1a96284c", @@ -276,150 +311,49 @@ describe("download-manager", () => { "1a962851", ]) - expect(await readdir("meta")).toEqual([hash_id]) - expect(await readdir("poster")).toEqual([hash_id]) - // valid image pages for (const index in pages) { - const path = `files/${hash_id}/${hashSum(+index)}` + const path = `files/${hashIDManga}/${hashIDEp}/${hashSum(+index)}` expect(await readFile(path, Encoding.UTF8)).toBe(pages[index]) } // valid meta expect( - JSON.parse(await readFile("meta/" + hash_id, Encoding.UTF8)) + JSON.parse( + await readFile( + "meta/" + hashIDManga + "/" + hashIDEp + ".mod", + Encoding.UTF8 + ) + ) ).toEqual({ - path: "/manga-1/chap-1", - manga_id: 1, - manga_name: "Manga 1", - ep_id, - manga_image: "offline://poster/" + hash_id, - manga_image_downloaded: true, - ep_name: "Chapter 1", - pages: pages.map( - (_, index) => `offline://files/${hash_id}/${hashSum(index)}` - ), - downloaded: pages.length, + path: pathEp, start_download_at: 1690022500169, + downloaded: 8, + ep_id, + ep_name, + pages: [ + "offline://files/1a96284b/30089c30/1a96284a", + "offline://files/1a96284b/30089c30/1a96284b", + "offline://files/1a96284b/30089c30/1a96284c", + "offline://files/1a96284b/30089c30/1a96284d", + "offline://files/1a96284b/30089c30/1a96284e", + "offline://files/1a96284b/30089c30/1a96284f", + "offline://files/1a96284b/30089c30/1a962850", + "offline://files/1a96284b/30089c30/1a962851", + ], }) - - // valid meta image - expect(await readFile("poster/" + hash_id, Encoding.UTF8)).toBe(manga_image) - }) - - test("should get list episodes downloaded", async () => { - ;(fetch as ReturnType).mockImplementation(async (url) => { - return Promise.resolve({ - async arrayBuffer() { - return new TextEncoder().encode(url) - }, - async text() { - return url - }, - }) - }) - - await createTaskDownloadEpisode(meta).start() - - // ok get list - - expect(await getListEpisodes()).toEqual([ - { - path: "/manga-1/chap-1", - manga_id: 1, - manga_name: "Manga 1", - manga_image: "offline://poster/33e5d30a", - ep_id, - ep_name: "Chapter 1", - pages: [ - "offline://files/33e5d30a/1a96284a", - "offline://files/33e5d30a/1a96284b", - "offline://files/33e5d30a/1a96284c", - "offline://files/33e5d30a/1a96284d", - "offline://files/33e5d30a/1a96284e", - "offline://files/33e5d30a/1a96284f", - "offline://files/33e5d30a/1a962850", - "offline://files/33e5d30a/1a962851", - ], - manga_image_downloaded: true, - downloaded: 8, - start_download_at: 1690022500169, - }, - ]) - - const meta2 = { - ...meta, - manga_id: 2, - } - ;(Date.now as ReturnType).mockReturnValue(1690022500190) - - await createTaskDownloadEpisode(meta2).start() - - expect(await getListEpisodes()).toEqual([ - { - path: "/manga-1/chap-1", - manga_id: 1, - manga_name: "Manga 1", - manga_image: "offline://poster/33e5d30a", - ep_id, - ep_name: "Chapter 1", - pages: [ - "offline://files/33e5d30a/1a96284a", - "offline://files/33e5d30a/1a96284b", - "offline://files/33e5d30a/1a96284c", - "offline://files/33e5d30a/1a96284d", - "offline://files/33e5d30a/1a96284e", - "offline://files/33e5d30a/1a96284f", - "offline://files/33e5d30a/1a962850", - "offline://files/33e5d30a/1a962851", - ], - manga_image_downloaded: true, - downloaded: 8, - start_download_at: 1690022500169, - }, - { - path: "/manga-1/chap-1", - manga_id: 2, - manga_name: "Manga 1", - manga_image: "offline://poster/359aaba9", - ep_id, - ep_name: "Chapter 1", - pages: [ - "offline://files/359aaba9/1a96284a", - "offline://files/359aaba9/1a96284b", - "offline://files/359aaba9/1a96284c", - "offline://files/359aaba9/1a96284d", - "offline://files/359aaba9/1a96284e", - "offline://files/359aaba9/1a96284f", - "offline://files/359aaba9/1a962850", - "offline://files/359aaba9/1a962851", - ], - manga_image_downloaded: true, - downloaded: 8, - start_download_at: 1690022500190, - }, - ]) }) // test("should run multiple download episodes", async () => {}) test("should download stop and resume", async () => { - ;(fetch as ReturnType).mockImplementation(async (url) => { - await sleep(500) - - return Promise.resolve({ - async arrayBuffer() { - return new TextEncoder().encode(url) - }, - async text() { - return url - }, - }) - }) + patchFetch() - const { ref, downloading, start, stop, resume } = - createTaskDownloadEpisode(meta) + const { ref, downloading, start, stop, resume } = createTaskDownloadEpisode( + metaManga, + metaEp + ) expect(downloading.value).toBe(false) expect(ref.value.downloaded).toBe(0) @@ -446,13 +380,8 @@ describe("download-manager", () => { await start() - const hash_id = hashSum(`${manga_id}ɣ${ep_id}`) - // check directory - expect(await readdir("")).toEqual(["files", "meta", "poster"]) - expect(await readdir("files")).toEqual([hash_id]) - // check hash file page - expect(await readdir(`files/${hash_id}`)).toEqual([ + expect(await readdir(`files/${hashIDManga}/${hashIDEp}`)).toEqual([ "1a96284a", "1a96284b", "1a96284c", @@ -463,37 +392,217 @@ describe("download-manager", () => { "1a962851", ]) - expect(await readdir("meta")).toEqual([hash_id]) - expect(await readdir("poster")).toEqual([hash_id]) - // valid image pages for (const index in pages) { - const path = `files/${hash_id}/${hashSum(+index)}` + const path = `files/${hashIDManga}/${hashIDEp}/${hashSum(+index)}` expect(await readFile(path, Encoding.UTF8)).toBe(pages[index]) } // valid meta expect( - JSON.parse(await readFile("meta/" + hash_id, Encoding.UTF8)) + JSON.parse( + await readFile( + "meta/" + hashIDManga + "/" + hashIDEp + ".mod", + Encoding.UTF8 + ) + ) ).toEqual({ - path: "/manga-1/chap-1", - manga_id: 1, - manga_name: "Manga 1", + path: pathEp, + start_download_at: 1690022500169, + downloaded: 8, ep_id, - manga_image: "offline://poster/" + hash_id, - manga_image_downloaded: true, - ep_name: "Chapter 1", - pages: pages.map( - (_, index) => `offline://files/${hash_id}/${hashSum(index)}` - ), - downloaded: pages.length, - start_download_at: 1690022500190, + ep_name, + pages: [ + "offline://files/1a96284b/30089c30/1a96284a", + "offline://files/1a96284b/30089c30/1a96284b", + "offline://files/1a96284b/30089c30/1a96284c", + "offline://files/1a96284b/30089c30/1a96284d", + "offline://files/1a96284b/30089c30/1a96284e", + "offline://files/1a96284b/30089c30/1a96284f", + "offline://files/1a96284b/30089c30/1a962850", + "offline://files/1a96284b/30089c30/1a962851", + ], }) + }) - // valid meta image - expect(await readFile("poster/" + hash_id, Encoding.UTF8)).toBe(manga_image) - // expect(watcher.mock.calls.length).toBe(10) + test("should get list manga downloaded", async () => { + patchFetch() + + await createTaskDownloadEpisode(metaManga, metaEp).start() + + // ok get list + + expect(await getListManga()).toEqual([ + { + path, + manga_id, + manga_image: "offline:///poster/1a96284b", + manga_name, + start_download_at: 1690022500169, + }, + ]) + + const meta2 = { + ...metaManga, + manga_id: 2, + } + ;(Date.now as ReturnType).mockReturnValue(1690022500190) + + await createTaskDownloadEpisode(meta2, metaEp).start() + + expect(await getListManga()).toEqual([ + { + path, + manga_id, + manga_image: "offline:///poster/1a96284b", + manga_name, + start_download_at: 1690022500169, + }, + { + path, + manga_id: 2, + manga_image: "offline:///poster/1a96284c", + manga_name, + start_download_at: 1690022500190, + }, + ]) + }) + + test("should get list episodes downloaded", async () => { + patchFetch() + + await createTaskDownloadEpisode(metaManga, metaEp).start() + + // ok get list + expect(await getListEpisodes(manga_id)).toEqual([ + { + path: pathEp, + start_download_at: 1690022500190, + downloaded: 8, + ep_id, + ep_name, + pages: [ + "offline://files/1a96284b/30089c30/1a96284a", + "offline://files/1a96284b/30089c30/1a96284b", + "offline://files/1a96284b/30089c30/1a96284c", + "offline://files/1a96284b/30089c30/1a96284d", + "offline://files/1a96284b/30089c30/1a96284e", + "offline://files/1a96284b/30089c30/1a96284f", + "offline://files/1a96284b/30089c30/1a962850", + "offline://files/1a96284b/30089c30/1a962851", + ], + }, + ]) + ;(Date.now as ReturnType).mockReturnValue(1690022500190) + + await createTaskDownloadEpisode(metaManga, { + ...metaEp, + ep_id: metaEp.ep_id + 1, + }).start() + + expect(await getListEpisodes(manga_id)).toEqual([ + { + path: pathEp, + start_download_at: 1690022500190, + downloaded: 8, + ep_id: 1235, + ep_name, + pages: [ + "offline://files/1a96284b/30089c2e/1a96284a", + "offline://files/1a96284b/30089c2e/1a96284b", + "offline://files/1a96284b/30089c2e/1a96284c", + "offline://files/1a96284b/30089c2e/1a96284d", + "offline://files/1a96284b/30089c2e/1a96284e", + "offline://files/1a96284b/30089c2e/1a96284f", + "offline://files/1a96284b/30089c2e/1a962850", + "offline://files/1a96284b/30089c2e/1a962851", + ], + }, + { + path: pathEp, + start_download_at: 1690022500190, + downloaded: 8, + ep_id, + ep_name, + pages: [ + "offline://files/1a96284b/30089c30/1a96284a", + "offline://files/1a96284b/30089c30/1a96284b", + "offline://files/1a96284b/30089c30/1a96284c", + "offline://files/1a96284b/30089c30/1a96284d", + "offline://files/1a96284b/30089c30/1a96284e", + "offline://files/1a96284b/30089c30/1a96284f", + "offline://files/1a96284b/30089c30/1a962850", + "offline://files/1a96284b/30089c30/1a962851", + ], + }, + ]) + }) + + test("should delete manga", async () => { + patchFetch() + + await createTaskDownloadEpisode(metaManga, metaEp).start() + + await expect(exists(`meta/${hashIDManga}`)).resolves.toBeTruthy() + await expect(exists(`poster/${hashIDManga}`)).resolves.toBeTruthy() + await expect(exists(`files/${hashIDManga}`)).resolves.toBeTruthy() + + await deleteManga(manga_id) + + await expect(exists(`meta/${hashIDManga}`)).resolves.toBeFalsy() + await expect(exists(`poster/${hashIDManga}`)).resolves.toBeFalsy() + await expect(exists(`files/${hashIDManga}`)).resolves.toBeFalsy() + }) + + test("should delete episode", async () => { + patchFetch() + + await createTaskDownloadEpisode(metaManga, metaEp).start() + await createTaskDownloadEpisode(metaManga, { + ...metaEp, + ep_id: metaEp.ep_id + 1, + }).start() + + await expect(exists(`meta/${hashIDManga}`)).resolves.toBeTruthy() + await expect(exists(`poster/${hashIDManga}`)).resolves.toBeTruthy() + await expect(exists(`files/${hashIDManga}`)).resolves.toBeTruthy() + + await expect( + exists(`meta/${hashIDManga}/${hashIDEp}.mod`) + ).resolves.toBeTruthy() + await expect( + exists(`files/${hashIDManga}/${hashIDEp}`) + ).resolves.toBeTruthy() + + const hashIDEp2 = hashSum(metaEp.ep_id + 1) + await expect( + exists(`meta/${hashIDManga}/${hashIDEp2}.mod`) + ).resolves.toBeTruthy() + await expect( + exists(`files/${hashIDManga}/${hashIDEp2}`) + ).resolves.toBeTruthy() + + await deleteEpisode(manga_id, ep_id) + + await expect( + exists(`meta/${hashIDManga}/${hashIDEp}.mod`) + ).resolves.toBeFalsy() + await expect( + exists(`files/${hashIDManga}/${hashIDEp}`) + ).resolves.toBeFalsy() + await expect( + exists(`meta/${hashIDManga}/${hashIDEp2}.mod`) + ).resolves.toBeTruthy() + await expect( + exists(`files/${hashIDManga}/${hashIDEp2}`) + ).resolves.toBeTruthy() + + await deleteEpisode(manga_id, ep_id + 1) + + await expect(exists(`meta/${hashIDManga}.mod`)).resolves.toBeFalsy() + await expect(exists(`poster/${hashIDManga}`)).resolves.toBeFalsy() + await expect(exists(`files/${hashIDManga}`)).resolves.toBeFalsy() }) }) diff --git a/src/logic/download-manager.ts b/src/logic/download-manager.ts index e311211e..0163a533 100644 --- a/src/logic/download-manager.ts +++ b/src/logic/download-manager.ts @@ -1,12 +1,36 @@ /* eslint-disable camelcase */ import hashSum from "hash-sum" -export interface MetaEpisode { +/* +. +├── meta/ +│ ├── [hash_id_manga]/ +│ │ └── [hash_id_ep].mod +│ └── [hash_id_manga].mod +├── files/ +│ └── [hash_id_manga] +│ └── [hash_id_ep] +└── poster/ + └── [hash_id_manga] +*/ + +const DIR_META = "meta" +const DIR_POSTER = "poster" +const DIR_FILES = "files" +const PROTOCOL_OFFLINE = "offline://" + +export interface MetaManga { readonly path: string readonly manga_id: number readonly manga_name: string readonly manga_image: string +} +export interface MetaMangaOnDisk extends MetaManga { + readonly start_download_at: number +} +export interface MetaEpisode { + readonly path: string readonly ep_id: number readonly ep_name: string @@ -14,14 +38,10 @@ export interface MetaEpisode { readonly pages: readonly string[] } export interface MetaEpisodeOnDisk extends MetaEpisode { - readonly manga_image_downloaded: boolean readonly downloaded: number readonly start_download_at: number } -export interface MetaEpisodeRunning - extends Omit { - manga_image_downloaded: boolean - manga_image: string +export interface MetaEpisodeRunning extends MetaEpisodeOnDisk { downloaded: number pages: string[] } @@ -37,82 +57,101 @@ async function downloadFile(src: string, path: string): Promise { }) } -export async function getListEpisodes() { - // return - const { files } = await Filesystem.readdir({ - path: "meta", - directory: Directory.External, - }) - - const list = ( - await Promise.all( - files.map((item) => - Filesystem.readFile({ - path: `meta/${item.name}`, - directory: Directory.External, - encoding: Encoding.UTF8, - }) - .then((res) => JSON.parse(res.data) as MetaEpisodeOnDisk) - .catch(() => null) - ) - ) - ).filter(Boolean) as MetaEpisodeOnDisk[] - - list.sort((a, b) => a.start_download_at - b.start_download_at) - - return list -} - async function downloadFiles( sources: readonly string[], - hash_id: string, + hashIDManga: string, + hashIDEp: string, startIndex: number, onprogress: (cur: number, total: number, path: string) => boolean ): Promise { - await someLimit(sources, async (src: string, index: number) => { - const path = `files/${hash_id}/${hashSum(startIndex + index)}` - await downloadFile(src, path) + await someLimit( + sources, + async (src: string, index: number) => { + const path = `${DIR_FILES}/${hashIDManga}/${hashIDEp}/${hashSum( + startIndex + index + )}` + await downloadFile(src, path) - return onprogress(index, sources.length, "offline://" + path) - }, 5) + return onprogress(index, sources.length, PROTOCOL_OFFLINE + path) + }, + 5 + ) } -export function createTaskDownloadEpisode(meta: MetaEpisode) { - const id = `${meta.manga_id}ɣ${meta.ep_id}` - const hash_id = hashSum(id) +async function saveMetaManga(metaManga: MetaManga): Promise { + const hash_id = hashSum(metaManga.manga_id) + + const path = `${DIR_META}/${hash_id}.mod` + + // check + try { + const val = JSON.parse( + await Filesystem.readFile({ + path, + directory: Directory.External, + encoding: Encoding.UTF8, + }).then((res) => res.data) + ) + + if (val) return val as MetaMangaOnDisk + } catch {} + + const pathPoster = `${DIR_POSTER}/${hash_id}` + await downloadFile(metaManga.manga_image, pathPoster) + + const metaOnDisk: MetaMangaOnDisk = { + ...metaManga, + manga_image: `${PROTOCOL_OFFLINE}/${pathPoster}`, + start_download_at: Date.now(), + } + await Filesystem.writeFile({ + path, + directory: Directory.External, + encoding: Encoding.UTF8, + data: JSON.stringify(metaOnDisk), + }) + + return metaOnDisk +} + +export function createTaskDownloadEpisode( + metaMannga: MetaManga, + metaEp: MetaEpisode +) { + const hashIDEp = hashSum(metaEp.ep_id) const downloading = ref(false) const refValue = ref({ start_download_at: Date.now(), - ...meta, - pages: meta.pages as string[], - manga_image_downloaded: false, downloaded: 0, + ...metaEp, + pages: metaEp.pages as string[], }) + const startSaveMetaManga = () => saveMetaManga(metaMannga) const start = async (loadMetaOnDisk = true) => { downloading.value = true + const hashIDManga = hashSum((await startSaveMetaManga()).manga_id) + // check continue this passed const metaInDisk = loadMetaOnDisk ? await Filesystem.readFile({ - path: `meta/${hash_id}`, + path: `${DIR_META}/${hashIDManga}/${hashIDEp}.mod`, directory: Directory.External, encoding: Encoding.UTF8, }) .then( (res) => - JSON.parse(res.data) as MetaEpisode & { downloaded: number } + JSON.parse(res.data) as MetaEpisodeOnDisk & { + downloaded: number + } ) .catch(() => undefined) : undefined // save meta const metaCloned = Object.assign(refValue.value, metaInDisk, { - pages: mergeArray(meta.pages, metaInDisk?.pages), - manga_image: - metaInDisk?.manga_image ?? - refValue.value.manga_image ?? - meta.manga_image, + pages: mergeArray(metaEp.pages, metaInDisk?.pages), }) let timeout: NodeJS.Timeout | number @@ -124,7 +163,7 @@ export function createTaskDownloadEpisode(meta: MetaEpisode) { timeout = setTimeout(async () => { try { await Filesystem.writeFile({ - path: `meta/${hash_id}`, + path: `${DIR_META}/${hashIDManga}/${hashIDEp}.mod`, data: JSON.stringify(metaCloned), directory: Directory.External, encoding: Encoding.UTF8, @@ -137,22 +176,14 @@ export function createTaskDownloadEpisode(meta: MetaEpisode) { }) } - // save poster - if (!metaCloned.manga_image_downloaded) { - const manga_image = `poster/${hash_id}` - await downloadFile(metaCloned.manga_image, manga_image) - metaCloned.manga_image = "offline://" + manga_image - metaCloned.manga_image_downloaded = true - await saveMeta() - } - if (!downloading.value) return const startIndex = metaCloned.downloaded // save files await downloadFiles( - meta.pages.slice(startIndex), - hash_id, + metaEp.pages.slice(startIndex), + hashIDManga, + hashIDEp, startIndex, (cur, total, path) => { metaCloned.pages[cur + startIndex] = path @@ -175,31 +206,153 @@ export function createTaskDownloadEpisode(meta: MetaEpisode) { } const resume = () => start(false) - return { ref: refValue, downloading, start, stop, resume } + return { ref: refValue, startSaveMetaManga, downloading, start, stop, resume } } -export async function deleteEpisode(manga_id: string, ep_id: string) { - const id = `${manga_id}ɣ${ep_id}` - const hash_id = hashSum(id) +export async function getListManga() { + // return + const { files } = await Filesystem.readdir({ + path: DIR_META, + directory: Directory.External, + }) + + const list = ( + await Promise.all( + // eslint-disable-next-line array-callback-return + files.map((item) => { + if (item.type === "file") + return Filesystem.readFile({ + path: `${DIR_META}/${item.name}`, + directory: Directory.External, + encoding: Encoding.UTF8, + }).then((res) => JSON.parse(res.data) as MetaMangaOnDisk) + // .catch(() => null) + }) + ) + ).filter(Boolean) as MetaMangaOnDisk[] + + list.sort((a, b) => a.start_download_at - b.start_download_at) + + return list +} + +export async function getListEpisodes(manga_id: number) { + const hashIDManga = hashSum(manga_id) + + const { files } = await Filesystem.readdir({ + path: `${DIR_META}/${hashIDManga}`, + directory: Directory.External, + }) + + return Promise.all( + files.map( + (item) => + item.type === "file" && + Filesystem.readFile({ + path: `${DIR_META}/${hashIDManga}/${item.name}`, + directory: Directory.External, + encoding: Encoding.UTF8, + }).then((res) => JSON.parse(res.data) as MetaEpisodeOnDisk) + ) + ).then((list) => list.filter(Boolean) as MetaEpisodeOnDisk[]) +} + +export async function deleteManga(manga_id: number) { + const hashIDManga = hashSum(manga_id) await Promise.all([ - // remove meta + // remove meta episodes + Filesystem.rmdir({ + path: `${DIR_META}/${hashIDManga}`, + directory: Directory.External, + recursive: true, + }).catch(() => null), + + // remove meta manga Filesystem.deleteFile({ - path: `meta/${hash_id}`, + path: `${DIR_META}/${hashIDManga}.mod`, directory: Directory.External, }).catch(() => null), // remove poster Filesystem.deleteFile({ - path: `poster/${hash_id}`, + path: `${DIR_POSTER}/${hashIDManga}`, directory: Directory.External, }).catch(() => null), - // remove pages + // remove pages of manga Filesystem.rmdir({ - path: `files/${hash_id}`, + path: `${DIR_FILES}/${hashIDManga}`, directory: Directory.External, recursive: true, }).catch(() => null), ]) } + +export async function deleteEpisode(manga_id: number, ep_id: number) { + const hashIDManga = hashSum(manga_id) + const hashIDEp = hashSum(ep_id) + + await Promise.all([ + // remove meta + Filesystem.deleteFile({ + path: `${DIR_META}/${hashIDManga}/${hashIDEp}.mod`, + directory: Directory.External, + }) + .then(async () => { + // check removed all episode + const { files } = await Filesystem.readdir({ + path: `${DIR_META}/${hashIDManga}`, + directory: Directory.External, + }) + + if (files.length === 0) { + // remove poster and meta manga + await Promise.all([ + // remove poster + Filesystem.deleteFile({ + path: `${DIR_POSTER}/${hashIDManga}`, + directory: Directory.External, + // eslint-disable-next-line promise/no-nesting + }).catch(() => null), + // remove meta manga + Filesystem.deleteFile({ + path: `${DIR_META}/${hashIDManga}.mod`, + directory: Directory.External, + // eslint-disable-next-line promise/no-nesting + }).catch(() => null), + ]) + } + + // eslint-disable-next-line no-useless-return + return + }) + .catch(() => null), + + // remove pages + Filesystem.rmdir({ + path: `${DIR_FILES}/${hashIDManga}/${hashIDEp}`, + directory: Directory.External, + recursive: true, + }) + .then(async () => { + // check removed all episode + const { files } = await Filesystem.readdir({ + path: `${DIR_FILES}/${hashIDManga}`, + directory: Directory.External, + }) + + if (files.length === 0) { + await Filesystem.rmdir({ + path: `${DIR_FILES}/${hashIDManga}`, + directory: Directory.External, + recursive: true, + }) + } + + // eslint-disable-next-line no-useless-return + return + }) + .catch(() => null), + ]) +} diff --git a/src/stores/IDM.ts b/src/stores/IDM.ts index bed34595..86eddab7 100644 --- a/src/stores/IDM.ts +++ b/src/stores/IDM.ts @@ -1,30 +1,44 @@ import { defineStore } from "pinia" -import type { MetaEpisode, MetaEpisodeOnDisk } from "src/logic/download-manager" +import type { + MetaEpisode, + MetaEpisodeOnDisk, + MetaManga, + MetaMangaOnDisk, +} from "src/logic/download-manager" Object.assign(window, { Filesystem, Directory, Encoding }) export const useIDMStore = defineStore("IDM", () => { const _queue = shallowReactive< - (MetaEpisodeOnDisk | ReturnType)[] - >([]) + Map< + MetaMangaOnDisk, + Set> + > + >(new Map()) + const listManga = shallowReactive([]) let gettedList = false const queue = computed(() => { if (!gettedList) { - getListEpisodes().then((list) => { - _queue.push(...list) - _queue.sort((a, b) => b.created_at - a.created_at) + // eslint-disable-next-line promise/catch-or-return, promise/always-return + getListManga().then((list) => { + listManga.push(...list) + listManga.sort((a, b) => b.start_download_at - a.start_download_at) + listManga.forEach((item) => _queue.set(item, new Set())) }) + gettedList = true } return _queue }) - function download( - meta: MetaEpisode | ReturnType - ) { - if ("ep_id" in meta) { - _queue.unshift(task) - task.start() - } else meta.start() + async function download(metaManga: MetaManga, metaEp: MetaEpisode) { + const task = createTaskDownloadEpisode(metaManga, metaEp) + const manga = await task.startSaveMetaManga() + + listManga.unshift(manga) + + const store = _queue.get(manga) + if (store) store.add(task) + else _queue.set(manga, new Set([task])) } return { queue, download } diff --git a/test/vitest/utils.ts b/test/vitest/utils.ts index cd3f3c66..bf4f5217 100644 --- a/test/vitest/utils.ts +++ b/test/vitest/utils.ts @@ -31,3 +31,9 @@ export function readFile(path: string, encoding?: Encoding) { encoding, }).then((res) => res.data) } + +export function exists(path: string) { + return Filesystem.stat({ path, directory: Directory.External }) + .then(() => true) + .catch(() => false) +} diff --git a/vitest.config.ts b/vitest.config.ts index e3fa30bb..3dfff0cd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ environment: "happy-dom", globals: true, setupFiles: "test/vitest/setup-file.ts", - testTimeout: 5000, + testTimeout: 5_000, include: [ // Matches vitest tests in any subfolder of 'src' or into 'test/vitest/__tests__' // Matches all files with extension 'js', 'jsx', 'ts' and 'tsx'