mirror of
https://github.com/danbulant/Mangades
synced 2026-05-24 12:22:10 +00:00
add cover art and automatic splitting options to downloads along with preview
This commit is contained in:
parent
4bc9a5e733
commit
020bd06203
9 changed files with 397 additions and 154 deletions
3
src/global.d.ts
vendored
3
src/global.d.ts
vendored
|
|
@ -5,4 +5,7 @@ declare namespace App {
|
||||||
message: string;
|
message: string;
|
||||||
code: string;
|
code: string;
|
||||||
}
|
}
|
||||||
|
interface Window {
|
||||||
|
goatcounter: any;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import request, { imageproxy } from "$lib/util/request";
|
import request, { coverUrl, imageproxy } from "$lib/util/request";
|
||||||
|
|
||||||
// export var mangaId: string;
|
// export var mangaId: string;
|
||||||
export var additionalList: {
|
export var additionalList: {
|
||||||
|
|
@ -23,14 +23,18 @@
|
||||||
Loading art
|
Loading art
|
||||||
{:then list}
|
{:then list}
|
||||||
{#each list.data.sort((a, b) => a.attributes.volume - b.attributes.volume) as item}
|
{#each list.data.sort((a, b) => a.attributes.volume - b.attributes.volume) as item}
|
||||||
<img
|
<div class="img-container">
|
||||||
on:click={() => (selectedImage = `${imageproxy}https://uploads.mangadex.org/covers/${mangaId}/${item.attributes.fileName}.512.jpg`)}
|
<img
|
||||||
width="512"
|
on:click={() => (selectedImage = coverUrl(mangaId, item))}
|
||||||
height="805"
|
width="512"
|
||||||
src="{imageproxy}https://uploads.mangadex.org/covers/{mangaId}/{item.attributes.fileName}.512.jpg"
|
height="805"
|
||||||
alt=""
|
src={coverUrl(mangaId, item)}
|
||||||
class="color"
|
alt=""
|
||||||
draggable={false} />
|
draggable={false} />
|
||||||
|
<img class="img-backdrop"
|
||||||
|
src={coverUrl(mangaId, item)}
|
||||||
|
alt="">
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/await}
|
{/await}
|
||||||
{#each additionalList as item}
|
{#each additionalList as item}
|
||||||
|
|
@ -99,7 +103,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
.main > img:first-child {
|
.main > :first-child {
|
||||||
grid-column: 1 / span 2;
|
grid-column: 1 / span 2;
|
||||||
grid-row: 1 / span 2;
|
grid-row: 1 / span 2;
|
||||||
height: 20rem;
|
height: 20rem;
|
||||||
|
|
|
||||||
38
src/lib/components/fileItems.svelte
Normal file
38
src/lib/components/fileItems.svelte
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { flip } from "svelte/animate";
|
||||||
|
import { blur } from "svelte/transition";
|
||||||
|
import Item from "./item.svelte";
|
||||||
|
import { showType } from "./showTypeChooser.svelte";
|
||||||
|
|
||||||
|
export var chapters: {
|
||||||
|
cover: string;
|
||||||
|
title: string;
|
||||||
|
chapters: string[]
|
||||||
|
}[];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="items" class:list={$showType == "list"}>
|
||||||
|
{#each chapters as entry (entry.title)}
|
||||||
|
<div animate:flip>
|
||||||
|
<Item
|
||||||
|
cover={entry.cover}
|
||||||
|
title={entry.title}
|
||||||
|
description="Chapters {entry.chapters.join(", ")}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.items {
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(7rem, 12rem));
|
||||||
|
}
|
||||||
|
.items.list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SvelteMarkdown from "svelte-markdown";
|
import SvelteMarkdown from "svelte-markdown";
|
||||||
import { flip } from "svelte/animate";
|
|
||||||
import { blur, crossfade } from "svelte/transition";
|
import { blur, crossfade } from "svelte/transition";
|
||||||
import { showNsfw } from "./showNsfwChooser.svelte";
|
import { showNsfw } from "./showNsfwChooser.svelte";
|
||||||
import { showType } from "./showTypeChooser.svelte";
|
import { showType } from "./showTypeChooser.svelte";
|
||||||
|
|
@ -36,7 +35,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
Broken art
|
Missing cover
|
||||||
{/if}
|
{/if}
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,16 @@ interface Chapter {
|
||||||
id: string,
|
id: string,
|
||||||
number: string,
|
number: string,
|
||||||
volume?: string,
|
volume?: string,
|
||||||
links: string[],
|
links?: string[],
|
||||||
hash: string,
|
hash?: string,
|
||||||
hashes: string[],
|
hashes?: string[],
|
||||||
baseUrl: string,
|
// baseUrl?: string,
|
||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Opts {
|
interface Opts {
|
||||||
quality: string,
|
coverUrl?: string
|
||||||
|
quality?: string,
|
||||||
file: WritableStream,
|
file: WritableStream,
|
||||||
title: string,
|
title: string,
|
||||||
id: string,
|
id: string,
|
||||||
|
|
@ -26,6 +27,30 @@ interface Opts {
|
||||||
onerror?: (error: Error) => void
|
onerror?: (error: Error) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cache = new Map
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | Chapter | any} chapter
|
||||||
|
* @returns {Promise<{ urls: string[], hashes: string[], hash: string[] }>}
|
||||||
|
*/
|
||||||
|
export function getURLs(chapter: string | Chapter): Promise<{ urls: string[], hashes: string[], hash: string }> {
|
||||||
|
if(typeof chapter === "object") chapter = chapter.id;
|
||||||
|
const quality = "data";
|
||||||
|
if (!cache.has(chapter))
|
||||||
|
cache.set(chapter, (async() => {
|
||||||
|
console.log(chapter)
|
||||||
|
const data = await request("at-home/server/" + chapter);
|
||||||
|
console.log(data)
|
||||||
|
let obj = {
|
||||||
|
urls: data.chapter[quality].map(t => `${data.baseUrl}/${quality}/${data.chapter.hash}/${t}`),
|
||||||
|
hashes: data.chapter[quality],
|
||||||
|
hash: data.chapter.hash
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
})())
|
||||||
|
return cache.get(chapter);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base generator, to be extended
|
* Base generator, to be extended
|
||||||
*/
|
*/
|
||||||
|
|
@ -42,15 +67,8 @@ export class BaseGenerator {
|
||||||
* @param {string | Chapter | any} chapter
|
* @param {string | Chapter | any} chapter
|
||||||
* @returns {Promise<{ urls: string[], hashes: string[], hash: string[] }>}
|
* @returns {Promise<{ urls: string[], hashes: string[], hash: string[] }>}
|
||||||
*/
|
*/
|
||||||
async getURLs(chapter) {
|
async getURLs(chapter: string | Chapter): Promise<{ urls: string[], hashes: string[], hash: string }> {
|
||||||
if(typeof chapter === "object") chapter = chapter.id;
|
return getURLs(chapter);
|
||||||
const data = await request("at-home/server/" + chapter);
|
|
||||||
console.log(data, this.opts);
|
|
||||||
return {
|
|
||||||
urls: data.chapter[this.opts.quality].map(t => `${data.baseUrl}/${this.opts.quality}/${data.chapter.hash}/${t}`),
|
|
||||||
hashes: data.chapter[this.opts.quality],
|
|
||||||
hash: data.chapter.hash
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -58,7 +76,7 @@ export class BaseGenerator {
|
||||||
* @param {Chapter} chapter
|
* @param {Chapter} chapter
|
||||||
* @returns {Promise<Response>}
|
* @returns {Promise<Response>}
|
||||||
*/
|
*/
|
||||||
async fetchImage(url, chapter) {
|
async fetchImage(url: string, chapter: Chapter): Promise<Response> {
|
||||||
var res;
|
var res;
|
||||||
try {
|
try {
|
||||||
res = await fetch(imageproxy + url);
|
res = await fetch(imageproxy + url);
|
||||||
|
|
@ -68,8 +86,8 @@ export class BaseGenerator {
|
||||||
}
|
}
|
||||||
if(Math.floor(res.status / 100) !== 2) {
|
if(Math.floor(res.status / 100) !== 2) {
|
||||||
for(var i = 0; i < RETRY_LIMIT; i++) {
|
for(var i = 0; i < RETRY_LIMIT; i++) {
|
||||||
chapter.baseUrl = await this.getURLs(chapter);
|
let baseUrl = await this.getURLs(chapter);
|
||||||
res = await fetch(chapter.baseUrl + "/" + url);
|
res = await fetch(baseUrl + "/" + url);
|
||||||
if(Math.floor(res.status / 100) === 2) return res;
|
if(Math.floor(res.status / 100) === 2) return res;
|
||||||
}
|
}
|
||||||
throw new Error("Retry limit reached");
|
throw new Error("Retry limit reached");
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,14 @@ export class CBZGenerator extends BaseGenerator {
|
||||||
chapter.number = chapterI;
|
chapter.number = chapterI;
|
||||||
}
|
}
|
||||||
const imageCountLength = chapter.links.length.toString().length;
|
const imageCountLength = chapter.links.length.toString().length;
|
||||||
|
if(this.opts.coverUrl) {
|
||||||
|
let coverExtension = this.opts.coverUrl.substr(this.opts.coverUrl.lastIndexOf(".") + 1);
|
||||||
|
const cover = new ZipPassThrough(`${this.opts.title} 00 cover.${coverExtension}`);
|
||||||
|
this.zip.add(cover);
|
||||||
|
const res = await fetch(this.opts.coverUrl);
|
||||||
|
const data = new Uint8Array(await res.arrayBuffer());
|
||||||
|
cover.push(data, true);
|
||||||
|
}
|
||||||
for(const i in chapter.links) {
|
for(const i in chapter.links) {
|
||||||
let url = chapter.links[i];
|
let url = chapter.links[i];
|
||||||
let hash = chapter.hashes[i];
|
let hash = chapter.hashes[i];
|
||||||
|
|
@ -57,5 +65,6 @@ export class CBZGenerator extends BaseGenerator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.zip.end();
|
this.zip.end();
|
||||||
|
this.callback(-1, -1, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +50,27 @@ export class EpubGenerator extends BaseGenerator {
|
||||||
|
|
||||||
this.callback(); // signals the template is ready
|
this.callback(); // signals the template is ready
|
||||||
|
|
||||||
|
if(this.opts.coverUrl) {
|
||||||
|
const res = await fetch(this.opts.coverUrl);
|
||||||
|
const cover = new ZipPassThrough("OEBPS/cover." + this.opts.coverUrl.substr(this.opts.coverUrl.lastIndexOf(".") + 1));
|
||||||
|
this.zip.add(cover);
|
||||||
|
const data = new Uint8Array(await res.arrayBuffer());
|
||||||
|
cover.push(data, true);
|
||||||
|
const textContent = new ZipPassThrough("OEBPS/cover.xhtml");
|
||||||
|
this.zip.add(textContent);
|
||||||
|
textContent.push(enc.encode(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Cover</title>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||||
|
<meta name="EPB-UUID" content=""/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img style="margin:auto;height:100%;" src="cover.${this.opts.coverUrl.substr(this.opts.coverUrl.lastIndexOf(".") + 1)}" />
|
||||||
|
</body>
|
||||||
|
</html>`), true);
|
||||||
|
}
|
||||||
|
|
||||||
for(const chapterI in this.opts.chapters) {
|
for(const chapterI in this.opts.chapters) {
|
||||||
const chapter = this.opts.chapters[chapterI];
|
const chapter = this.opts.chapters[chapterI];
|
||||||
if(chapter.number == null || chapter.number == undefined) chapter.number = chapterI;
|
if(chapter.number == null || chapter.number == undefined) chapter.number = chapterI;
|
||||||
|
|
@ -131,6 +152,8 @@ export class EpubGenerator extends BaseGenerator {
|
||||||
</metadata>
|
</metadata>
|
||||||
|
|
||||||
<manifest>
|
<manifest>
|
||||||
|
${this.opts.coverUrl ? `<item id="icover" href="cover.${this.opts.coverUrl.substr(this.opts.coverUrl.lastIndexOf(".") + 1)}" media-type="image/${this.opts.coverUrl.substr(this.opts.coverUrl.lastIndexOf(".") + 1) === "jpg" ? "jpeg" : this.opts.coverUrl.substr(this.opts.coverUrl.lastIndexOf(".") + 1)}"/>` : ""}
|
||||||
|
${this.opts.coverUrl ? `<item id="pcover" href="cover.xhtml" media-type="application/xhtml+xml" />` : ""}
|
||||||
${this.hashes.map((t, i) => ` <item id="i${i}" href="${t.hash}" fallback="fallback" media-type="image/${t.hash.substr(t.hash.lastIndexOf(".") + 1) === "jpg" ? "jpeg" : t.hash.substr(t.hash.lastIndexOf(".") + 1)}"/>`).join("\n")}
|
${this.hashes.map((t, i) => ` <item id="i${i}" href="${t.hash}" fallback="fallback" media-type="image/${t.hash.substr(t.hash.lastIndexOf(".") + 1) === "jpg" ? "jpeg" : t.hash.substr(t.hash.lastIndexOf(".") + 1)}"/>`).join("\n")}
|
||||||
${this.hashes.map((t, i) => ` <item id="p${i}" href="${i}.xhtml" media-type="application/xhtml+xml" />`).join("\n")}
|
${this.hashes.map((t, i) => ` <item id="p${i}" href="${i}.xhtml" media-type="application/xhtml+xml" />`).join("\n")}
|
||||||
|
|
||||||
|
|
@ -138,6 +161,7 @@ export class EpubGenerator extends BaseGenerator {
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
||||||
<spine toc="ncxtoc">
|
<spine toc="ncxtoc">
|
||||||
|
${this.opts.coverUrl ? `<itemref idref="pcover" linear="yes" />` : ""}
|
||||||
${this.hashes.map((t, i) => ` <itemref idref="p${i}" linear="yes" />`).join("\n")}
|
${this.hashes.map((t, i) => ` <itemref idref="p${i}" linear="yes" />`).join("\n")}
|
||||||
<itemref idref="fallback" linear="no" />
|
<itemref idref="fallback" linear="no" />
|
||||||
</spine>
|
</spine>
|
||||||
|
|
@ -165,6 +189,12 @@ export class EpubGenerator extends BaseGenerator {
|
||||||
</docAuthor>
|
</docAuthor>
|
||||||
|
|
||||||
<navMap>
|
<navMap>
|
||||||
|
${this.opts.coverUrl ? `<navPoint id="cover" playOrder="0">
|
||||||
|
<navLabel>
|
||||||
|
<text>Cover</text>
|
||||||
|
</navLabel>
|
||||||
|
<content src="cover.xhtml"/>
|
||||||
|
</navPoint>` : ""}
|
||||||
${this.opts.chapters.map((t, i) => `
|
${this.opts.chapters.map((t, i) => `
|
||||||
<navPoint id="p${this.hashes.findIndex(h => h.hash === t.hashes[0])}" playOrder="${i + 1}">
|
<navPoint id="p${this.hashes.findIndex(h => h.hash === t.hashes[0])}" playOrder="${i + 1}">
|
||||||
<navLabel>
|
<navLabel>
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,8 @@ function request(endpoint, query, type = "GET", body) {
|
||||||
}).then(resp => resp.json());
|
}).then(resp => resp.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function coverUrl(mangaId, item) {
|
||||||
|
return `${imageproxy}https://uploads.mangadex.org/covers/${mangaId}/${item.attributes.fileName}.512.jpg`
|
||||||
|
}
|
||||||
|
|
||||||
export default request;
|
export default request;
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import Chapter from "$lib/components/chapter.svelte";
|
import Chapter from "$lib/components/chapter.svelte";
|
||||||
import { EpubGenerator } from "$lib/util/generateEpub";
|
import { EpubGenerator } from "$lib/util/generateEpub";
|
||||||
import { CBZGenerator } from "$lib/util/generateCbz";
|
import { CBZGenerator } from "$lib/util/generateCbz";
|
||||||
import request, { imageproxy } from "$lib/util/request";
|
import request, { coverUrl, imageproxy } from "$lib/util/request";
|
||||||
import { arraysEqual } from "$lib/util/arrays";
|
import { arraysEqual } from "$lib/util/arrays";
|
||||||
import Tabs from "$lib/components/tabs/tabs.svelte";
|
import Tabs from "$lib/components/tabs/tabs.svelte";
|
||||||
import { slide } from "svelte/transition";
|
import { slide } from "svelte/transition";
|
||||||
|
|
@ -18,6 +18,23 @@
|
||||||
import RelatedManga from "./relatedManga.svelte";
|
import RelatedManga from "./relatedManga.svelte";
|
||||||
import { anilistInfo } from "./anilistInfo";
|
import { anilistInfo } from "./anilistInfo";
|
||||||
import { isLogedIn } from "$lib/util/anilist";
|
import { isLogedIn } from "$lib/util/anilist";
|
||||||
|
import { BaseGenerator, getURLs } from "$lib/util/baseGenerator";
|
||||||
|
import FileItems from "$lib/components/fileItems.svelte";
|
||||||
|
import ShowTypeChooser from "$lib/components/showTypeChooser.svelte";
|
||||||
|
|
||||||
|
enum CoverArt {
|
||||||
|
AutoVolume = "auto-volume",
|
||||||
|
FirstPage = "first-page"
|
||||||
|
}
|
||||||
|
enum Group {
|
||||||
|
Single = "single",
|
||||||
|
Chapter = "chapter",
|
||||||
|
Volume = "volume"
|
||||||
|
}
|
||||||
|
enum Format {
|
||||||
|
Epub = "epub",
|
||||||
|
Cbz = "cbz"
|
||||||
|
}
|
||||||
|
|
||||||
export var data;
|
export var data;
|
||||||
|
|
||||||
|
|
@ -30,7 +47,7 @@
|
||||||
var title = manga.title.en || manga.title.jp || Object.values(manga.title)[0];
|
var title = manga.title.en || manga.title.jp || Object.values(manga.title)[0];
|
||||||
$: title = manga.title.en || manga.title.jp || Object.values(manga.title)[0];
|
$: title = manga.title.en || manga.title.jp || Object.values(manga.title)[0];
|
||||||
|
|
||||||
const defaultLanguages = ["en", "uk"];
|
const defaultLanguages = ["en"];
|
||||||
let languages = defaultLanguages;
|
let languages = defaultLanguages;
|
||||||
|
|
||||||
let anilistData;
|
let anilistData;
|
||||||
|
|
@ -121,40 +138,11 @@
|
||||||
processQueue();
|
processQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
const generators = {
|
const generators: Record<Format, typeof BaseGenerator> = {
|
||||||
epub: EpubGenerator,
|
epub: EpubGenerator,
|
||||||
cbz: CBZGenerator
|
cbz: CBZGenerator
|
||||||
}
|
}
|
||||||
|
var format: Format = Format.Cbz;
|
||||||
function createGenerator(chapter, file) {
|
|
||||||
return new generators[format]({
|
|
||||||
file,
|
|
||||||
id: window.location.toString() + "-" + chapter.id,
|
|
||||||
language: chapter.attributes.translatedLanguage,
|
|
||||||
updatedAt: chapter.attributes.updatedAt,
|
|
||||||
title,
|
|
||||||
author: relationships.find(t => t.type === "author").attributes.name || "Unknown",
|
|
||||||
chapters: [{
|
|
||||||
id: chapter.id,
|
|
||||||
number: chapter.attributes.chapter,
|
|
||||||
volume: chapter.attributes.volume
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async function downloadSingle(chapter) {
|
|
||||||
const file = streamSaver.createWriteStream(`${title} ${chapter.attributes.chapter}.${format}`, {
|
|
||||||
writableStrategy: undefined, // (optional)
|
|
||||||
readableStrategy: undefined, // (optional)
|
|
||||||
});
|
|
||||||
|
|
||||||
const generator = createGenerator(chapter, file)
|
|
||||||
|
|
||||||
console.log(generator);
|
|
||||||
queue.push(generator);
|
|
||||||
processQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
var format = "cbz";
|
|
||||||
var selected = [];
|
var selected = [];
|
||||||
function select(chapter) {
|
function select(chapter) {
|
||||||
console.log("Selecting", chapter);
|
console.log("Selecting", chapter);
|
||||||
|
|
@ -170,63 +158,156 @@
|
||||||
text = defaultText;
|
text = defaultText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function downloadMulti() {
|
|
||||||
selected.sort((a, b) => a.attributes.chapter - b.attributes.chapter);
|
|
||||||
if(!selected.length) return;
|
|
||||||
if(selected.length === 1) {
|
|
||||||
downloadSingle(selected.shift());
|
|
||||||
selected = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const file = streamSaver.createWriteStream(`${title}.${format}`, {
|
|
||||||
writableStrategy: undefined, // (optional)
|
|
||||||
readableStrategy: undefined, // (optional)
|
|
||||||
});
|
|
||||||
const generator = new generators[format]({
|
|
||||||
file,
|
|
||||||
id: window.location.toString(),
|
|
||||||
language: selected[0].attributes.translatedLanguage,
|
|
||||||
updatedAt: new Date,
|
|
||||||
title: title,
|
|
||||||
author: relationships.find(t => t.type === "author").attributes.name || "Unknown",
|
|
||||||
chapters: selected.map(chapter => ({
|
|
||||||
id: chapter.id,
|
|
||||||
title: chapter.attributes.title,
|
|
||||||
number: chapter.attributes.chapter,
|
|
||||||
volume: chapter.attributes.volume
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(generator);
|
function splitSelectionIntoFiles(selected, group: Group) {
|
||||||
queue.push(generator);
|
if(!selected.length) return [];
|
||||||
selected = [];
|
selected.sort((a, b) => a.attributes.chapter - b.attributes.chapter);
|
||||||
processQueue();
|
let files = [];
|
||||||
|
|
||||||
|
switch(group) {
|
||||||
|
case Group.Single:
|
||||||
|
files[0] = selected
|
||||||
|
break;
|
||||||
|
case Group.Chapter:
|
||||||
|
files = selected.map(chapter => [chapter]);
|
||||||
|
break;
|
||||||
|
case Group.Volume:
|
||||||
|
files = selected.reduce((acc, chapter) => {
|
||||||
|
let last = acc[acc.length - 1];
|
||||||
|
if(!last || last[0].attributes.volume !== chapter.attributes.volume) {
|
||||||
|
acc.push([chapter]);
|
||||||
|
} else {
|
||||||
|
last.push(chapter);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return files
|
||||||
}
|
}
|
||||||
function downloadSeparate() {
|
|
||||||
selected.sort((a, b) => a.attributes.chapter - b.attributes.chapter);
|
function getNumberRanges(numbers) {
|
||||||
if(!selected.length) return;
|
let ranges = [];
|
||||||
if(selected.length === 1) {
|
let last = numbers[0];
|
||||||
downloadSingle(selected.shift());
|
let start = last;
|
||||||
selected = [];
|
for(let i = 1; i < numbers.length; i++) {
|
||||||
return;
|
if(numbers[i] - last > 1) {
|
||||||
|
ranges.push([start, last]);
|
||||||
|
start = numbers[i];
|
||||||
|
}
|
||||||
|
last = numbers[i];
|
||||||
|
}
|
||||||
|
ranges.push([start, last]);
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameRanges(ranges) {
|
||||||
|
return ranges.map(range => {
|
||||||
|
if(range[0] === range[1]) return range[0];
|
||||||
|
return `${range[0]}-${range[1]}`;
|
||||||
|
}).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNameOf(chapters) {
|
||||||
|
if(!chapters) return "Unknown";
|
||||||
|
if(chapters.length == 1) {
|
||||||
|
return `ch ${chapters[0].attributes.chapter}`;
|
||||||
|
}
|
||||||
|
// todo: check that it's the whole volume
|
||||||
|
let isSingleVolume = chapters[0].attributes.volume && chapters.find(t => t.attributes.volume !== chapters[0].attributes.volume) === undefined;
|
||||||
|
if(isSingleVolume) {
|
||||||
|
return `vol ${chapters[0].attributes.volume}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const chapter of selected) {
|
let chapterNumbers = chapters.map(t => t.attributes.chapter);
|
||||||
const file = streamSaver.createWriteStream(`${title} ${chapter.attributes.chapter}.${format}`, {
|
let ranges = getNumberRanges(chapterNumbers);
|
||||||
|
return `ch ${nameRanges(ranges)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coverForVolumeFromArt(volume, art) {
|
||||||
|
if(!volume) volume = "1"
|
||||||
|
let cover = art.data.find(t => t.attributes.volume === volume);
|
||||||
|
if(!cover) return null;
|
||||||
|
return coverUrl(mangaId, cover);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewItems(selected, groupMode, coverArtMode, format) {
|
||||||
|
let files = splitSelectionIntoFiles(selected, groupMode);
|
||||||
|
// const author = relationships.find(t => t.type === "author").attributes.name || "Unknown";
|
||||||
|
let items: {
|
||||||
|
cover: string;
|
||||||
|
title: string;
|
||||||
|
chapters: string[]
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
switch(coverArtMode) {
|
||||||
|
case CoverArt.FirstPage:
|
||||||
|
items = await Promise.all(files.map(async file => {
|
||||||
|
let urls = await getURLs(file[0])
|
||||||
|
console.log(file, urls)
|
||||||
|
return {
|
||||||
|
title: `${title} - ${getNameOf(file)}.${format}`,
|
||||||
|
chapters: file.map(chapter => `${chapter.attributes.chapter}`),
|
||||||
|
cover: imageproxy + urls.urls[0]
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
case CoverArt.AutoVolume:
|
||||||
|
let art = await list;
|
||||||
|
items = files.map(file => {
|
||||||
|
return {
|
||||||
|
title: `${title} - ${getNameOf(file)}.${format}`,
|
||||||
|
chapters: file.map(chapter => `${chapter.attributes.chapter}`),
|
||||||
|
cover: coverForVolumeFromArt(file[0].attributes.volume, art),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadMulti() {
|
||||||
|
if(!selected.length) return;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
if(window.goatcounter) window.goatcounter.count({
|
||||||
|
path: window.location.pathname + "/download",
|
||||||
|
title: "Download " + title,
|
||||||
|
});
|
||||||
|
else console.warn("Page change; GoatCounter not loaded (yet?)", window.location.pathname);
|
||||||
|
|
||||||
|
let files = splitSelectionIntoFiles(selected, group);
|
||||||
|
const author = relationships.find(t => t.type === "author").attributes.name || "Unknown";
|
||||||
|
let art = coverArt == CoverArt.AutoVolume ? await list : null;
|
||||||
|
|
||||||
|
for(let file of files) {
|
||||||
|
let name = `${title} - ${getNameOf(file)}`;
|
||||||
|
const stream = streamSaver.createWriteStream(`${name}.${format}`, {
|
||||||
writableStrategy: undefined, // (optional)
|
writableStrategy: undefined, // (optional)
|
||||||
readableStrategy: undefined, // (optional)
|
readableStrategy: undefined, // (optional)
|
||||||
});
|
});
|
||||||
|
const generator = new generators[format]({
|
||||||
const generator = createGenerator(chapter, file)
|
file: stream,
|
||||||
|
id: window.location.toString(),
|
||||||
|
language: file[0].attributes.translatedLanguage,
|
||||||
|
updatedAt: file.map(t => t.attributes.updatedAt).sort((a, b) => a - b)[0],
|
||||||
|
title: name,
|
||||||
|
author,
|
||||||
|
coverUrl: art && coverForVolumeFromArt(file[0].attributes.volume, art),
|
||||||
|
chapters: file.map(chapter => ({
|
||||||
|
id: chapter.id,
|
||||||
|
title: chapter.attributes.title,
|
||||||
|
number: chapter.attributes.chapter,
|
||||||
|
volume: chapter.attributes.volume
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
console.log(generator);
|
console.log(generator);
|
||||||
queue.push(generator);
|
queue.push(generator);
|
||||||
}
|
}
|
||||||
|
queue = queue;
|
||||||
selected = [];
|
selected = [];
|
||||||
processQueue();
|
processQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {BeforeUnloadEvent} e
|
* @param {BeforeUnloadEvent} e
|
||||||
*/
|
*/
|
||||||
|
|
@ -286,14 +367,12 @@
|
||||||
additionalImages.push({
|
additionalImages.push({
|
||||||
src: data.coverImage.large,
|
src: data.coverImage.large,
|
||||||
alt: "Cover image from anilist",
|
alt: "Cover image from anilist",
|
||||||
// color: data.coverImage.color,
|
|
||||||
height: 1,
|
height: 1,
|
||||||
width: 1
|
width: 1
|
||||||
});
|
});
|
||||||
additionalImages.push({
|
additionalImages.push({
|
||||||
src: data.bannerImage,
|
src: data.bannerImage,
|
||||||
alt: "Banner image from anilist",
|
alt: "Banner image from anilist",
|
||||||
// color: data.coverImage.color,
|
|
||||||
height: 1,
|
height: 1,
|
||||||
width: 3
|
width: 3
|
||||||
});
|
});
|
||||||
|
|
@ -313,7 +392,7 @@
|
||||||
if(loadingNextPage) return;
|
if(loadingNextPage) return;
|
||||||
console.log("Loading next page");
|
console.log("Loading next page");
|
||||||
loadingNextPage = true;
|
loadingNextPage = true;
|
||||||
chapters = await getMangaChapters(mangaId);
|
chapters = await getMangaChapters(mangaId, languages);
|
||||||
await tick();
|
await tick();
|
||||||
loadingNextPage = false;
|
loadingNextPage = false;
|
||||||
swiper.slideToClosest();
|
swiper.slideToClosest();
|
||||||
|
|
@ -330,7 +409,14 @@
|
||||||
let uniqueChapterCount;
|
let uniqueChapterCount;
|
||||||
$: uniqueChapterCount = chapters?.data.filter((t, i, a) => a.findIndex(t2 => Math.floor(t2.attributes.chapter) === Math.floor(t.attributes.chapter)) === i).length;
|
$: uniqueChapterCount = chapters?.data.filter((t, i, a) => a.findIndex(t2 => Math.floor(t2.attributes.chapter) === Math.floor(t.attributes.chapter)) === i).length;
|
||||||
|
|
||||||
let list: any;
|
let list: Promise<{ data: {
|
||||||
|
id: string,
|
||||||
|
type: string,
|
||||||
|
attributes: {
|
||||||
|
fileName: string,
|
||||||
|
volume: string,
|
||||||
|
}
|
||||||
|
}[] }>;
|
||||||
$: list = request(
|
$: list = request(
|
||||||
"cover?limit=50&manga[]=" + mangaId + languages.map(t => "&locales[]=" + t).join("")
|
"cover?limit=50&manga[]=" + mangaId + languages.map(t => "&locales[]=" + t).join("")
|
||||||
);
|
);
|
||||||
|
|
@ -345,6 +431,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let coverArt: CoverArt = CoverArt.FirstPage;
|
||||||
|
let group: Group = Group.Single;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:beforeunload={beforeUnload} bind:innerWidth={width} bind:scrollY bind:innerHeight />
|
<svelte:window on:beforeunload={beforeUnload} bind:innerWidth={width} bind:scrollY bind:innerHeight />
|
||||||
|
|
@ -500,28 +589,79 @@
|
||||||
>
|
>
|
||||||
<SwiperSlide>
|
<SwiperSlide>
|
||||||
<div class="chapter-list" style="min-height: 30rem;">
|
<div class="chapter-list" style="min-height: 30rem;">
|
||||||
<div class="state {state}">
|
{#if queue && queue.length > 0}
|
||||||
<div class="progress" style="width: {progress * 100}%;"></div>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
{text}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if queue.length > 0}
|
|
||||||
<p><i>{queue.length} downloads queued.</i></p>
|
<p><i>{queue.length} downloads queued.</i></p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="download">
|
<div class="download">
|
||||||
<select name="format" bind:value={format} id="select-format">
|
<div class="status">
|
||||||
<option value="cbz"><b>.cbz</b> Comic Book Zip</option>
|
<div class="state {state}">
|
||||||
<option value="epub"><b>.epub</b> Electronic publication</option>
|
<div class="progress" style="width: {progress * 100}%;"></div>
|
||||||
</select>
|
|
||||||
<button disabled={!selected.length} on:click={downloadMulti}>Download</button>
|
<p>
|
||||||
<button disabled={!selected.length} on:click={downloadSeparate}>Download Separate</button>
|
{text}
|
||||||
</div>
|
</p>
|
||||||
<div class="download-options">
|
</div>
|
||||||
|
<button class="download-btn" disabled={!selected.length} on:click={downloadMulti}>Download</button>
|
||||||
|
</div>
|
||||||
|
<div class="options">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Format</legend>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="format" value={Format.Cbz} bind:group={format}>
|
||||||
|
<b>.<abbr title="Comic Book Zip">cbz</abbr></b>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="format" value={Format.Epub} bind:group={format}>
|
||||||
|
<b>.<abbr title="Electronic Publication">epub</abbr></b>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Cover art</legend>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="cover" value={CoverArt.FirstPage} bind:group={coverArt}>
|
||||||
|
|
||||||
|
First page
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="cover" value={CoverArt.AutoVolume} bind:group={coverArt}>
|
||||||
|
|
||||||
|
From art, by volume
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Split files</legend>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="group" value={Group.Single} bind:group={group}>
|
||||||
|
|
||||||
|
Single file
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="group" value={Group.Chapter} bind:group={group}>
|
||||||
|
|
||||||
|
By chapter
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="group" value={Group.Volume} bind:group={group}>
|
||||||
|
|
||||||
|
By volume
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<p class="note">Splitting into multiple files may require browser permission.</p>
|
||||||
|
|
||||||
|
<h3>Preview</h3>
|
||||||
|
|
||||||
|
<ShowTypeChooser />
|
||||||
|
|
||||||
|
{#await previewItems(selected, group, coverArt, format)}
|
||||||
|
<div>Loading preview...</div>
|
||||||
|
{:then items}
|
||||||
|
<FileItems chapters={items} />
|
||||||
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
|
|
@ -548,7 +688,7 @@
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each chapters.data as chapter}
|
{#each chapters.data as chapter}
|
||||||
<Chapter read={alReadProgress && alReadProgress >= parseInt(chapter.attributes.chapter)} progress={(progressMap.get(chapter.id) || 0) / chapter.attributes.pages} {chapter} disabledDownload={!!progress} selected={selected.includes(chapter)} on:select={() => select(chapter)} on:download={() => downloadSingle(chapter)} />
|
<Chapter read={alReadProgress && alReadProgress >= parseInt(chapter.attributes.chapter)} progress={(progressMap.get(chapter.id) || 0) / chapter.attributes.pages} {chapter} disabledDownload={!!progress} selected={selected.includes(chapter)} on:select={() => select(chapter)} />
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -556,7 +696,7 @@
|
||||||
</div>
|
</div>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
<SwiperSlide>
|
<SwiperSlide>
|
||||||
<div class="art-list" style="min-height: 30rem; overflow: hidden;">
|
<div class="art-list" style="min-height: 30rem;">
|
||||||
<ArtList {mangaId} {list} bind:selectedImage additionalList={additionalImages} />
|
<ArtList {mangaId} {list} bind:selectedImage additionalList={additionalImages} />
|
||||||
</div>
|
</div>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
|
|
@ -583,6 +723,7 @@
|
||||||
{#if manga.links.al}
|
{#if manga.links.al}
|
||||||
<a target="_blank" href="https://anilist.co/manga/{manga.links.al}"><Favicon url="https://anilist.co" /> Anilist</a> <br>
|
<a target="_blank" href="https://anilist.co/manga/{manga.links.al}"><Favicon url="https://anilist.co" /> Anilist</a> <br>
|
||||||
{/if}
|
{/if}
|
||||||
|
<a target="_blank" href="https://mangadex.org/title/{mangaId}"><Favicon url="https://mangadex.org"/> Mangadex.org</a> <br>
|
||||||
{#if manga.links.ap}
|
{#if manga.links.ap}
|
||||||
<a target="_blank" href="https://www.anime-planet.com/manga/{manga.links.ap}"><Favicon url="https://anime-planet.com" /> Animeplanet</a> <br>
|
<a target="_blank" href="https://www.anime-planet.com/manga/{manga.links.ap}"><Favicon url="https://anime-planet.com" /> Animeplanet</a> <br>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -613,8 +754,6 @@
|
||||||
{#if manga.links.engtl}
|
{#if manga.links.engtl}
|
||||||
<a target="_blank" href="{manga.links.engtl}"><Favicon url={manga.links.engtl} /> engtl</a> <br>
|
<a target="_blank" href="{manga.links.engtl}"><Favicon url={manga.links.engtl} /> engtl</a> <br>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<a target="_blank" href="https://mangadex.org/title/{mangaId}"><Favicon url="https://mangadex.org"/> Mangadex.org</a>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -653,7 +792,6 @@
|
||||||
}
|
}
|
||||||
.langs {
|
.langs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
|
||||||
margin: 0 1rem;
|
margin: 0 1rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
@ -664,6 +802,9 @@
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
margin: 5px;
|
||||||
|
padding: 5px;
|
||||||
}
|
}
|
||||||
.langs button.enabled {
|
.langs button.enabled {
|
||||||
background: rgb(107, 107, 107);
|
background: rgb(107, 107, 107);
|
||||||
|
|
@ -866,14 +1007,14 @@
|
||||||
margin-block-start: 0;
|
margin-block-start: 0;
|
||||||
margin-block-end: 0;
|
margin-block-end: 0;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: rgb(214, 214, 214);
|
background: rgb(64,64,64);
|
||||||
border-radius: 5px 0 5px 5px;
|
border-radius: 5px 0 5px 5px;
|
||||||
}
|
}
|
||||||
:global(.dark main > .copyright.copyright.copyright) {
|
:global(.dark main > .copyright.copyright.copyright) {
|
||||||
background: rgb(64, 64, 64);
|
background: rgb(64, 64, 64);
|
||||||
}
|
}
|
||||||
.copyright-header {
|
.copyright-header {
|
||||||
background: rgb(214, 214, 214);
|
background: rgb(64,64,64);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
@ -885,18 +1026,6 @@
|
||||||
.copyright-header-active {
|
.copyright-header-active {
|
||||||
border-radius: 5px 5px 0 0;
|
border-radius: 5px 5px 0 0;
|
||||||
}
|
}
|
||||||
.download {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
.download select {
|
|
||||||
flex-grow: 1;
|
|
||||||
margin-inline: 5px;
|
|
||||||
}
|
|
||||||
.download button {
|
|
||||||
margin-inline: 5px;
|
|
||||||
}
|
|
||||||
main {
|
main {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -917,28 +1046,37 @@
|
||||||
|
|
||||||
.state {
|
.state {
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border-width: 4px;
|
|
||||||
border-style: solid;
|
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: all .3s;
|
transition: all .3s;
|
||||||
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
:global(.dark .state) {
|
:global(.dark .state) {
|
||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
|
.download .status {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.download .options {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.download .note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgb(175, 175, 175);
|
||||||
|
}
|
||||||
|
|
||||||
.state.idle {
|
.state.idle {
|
||||||
background: rgb(140, 209, 255);
|
background: transparent;
|
||||||
border-color: rgb(77, 184, 255);
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state.active {
|
.state.active {
|
||||||
background: rgb(255, 255, 81);
|
background: rgb(255, 255, 81);
|
||||||
border-color: yellow;
|
|
||||||
}
|
}
|
||||||
.state.error {
|
.state.error {
|
||||||
background: rgb(255, 103, 103);
|
background: rgb(255, 103, 103);
|
||||||
border-color: rgb(255, 59, 59);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.state p {
|
.state p {
|
||||||
|
|
@ -957,6 +1095,6 @@
|
||||||
border-bottom-left-radius: 10px;
|
border-bottom-left-radius: 10px;
|
||||||
}
|
}
|
||||||
.state.active .progress {
|
.state.active .progress {
|
||||||
background: rgb(140, 209, 255);
|
background: rgba(255,255,255, .1);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue