mirror of
https://github.com/hkalexling/Mango.git
synced 2026-04-30 00:00:43 -04:00
Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80344c3bf0 | |||
| 8a732804ae | |||
| 9df372f784 | |||
| cf7431b8b6 | |||
| 974b6cfe9b | |||
| 4fbe5b471c | |||
| 33e7e31fbc | |||
| 72fae7f5ed | |||
| f50a7e3b3e | |||
| 66c4037f2b | |||
| 2c022a07e7 | |||
| 91362dfc7d | |||
| 97168b65d8 | |||
| 6e04e249e7 | |||
| 16397050dd | |||
| 3f73591dd4 | |||
| ec25109fa5 | |||
| 96f1ef3dde | |||
| b56e16e1e1 | |||
| 9769e760a0 | |||
| 70ab198a33 | |||
| 44a6f822cd | |||
| 2c241a96bb | |||
| 219d4446d1 | |||
| d330db131e | |||
| de193906a2 | |||
| d13cfc045f | |||
| a3b2cdd372 | |||
| f4d7128b59 | |||
| 663c0c0b38 | |||
| 57b2f7c625 | |||
| 9489d6abfd | |||
| 670cf54957 | |||
| 2e09efbd62 | |||
| 523195d649 | |||
| be47f309b0 | |||
| 03e044a1aa | |||
| 4eaf271fa4 | |||
| 4b464ed361 | |||
| a9520d6f26 | |||
| a151ec486d | |||
| 8f1383a818 | |||
| f5933a48d9 | |||
| 7734dae138 | |||
| 8c90b46114 | |||
| cd48b45f11 | |||
| bdbdf9c94b | |||
| 7e36c91ea7 | |||
| 9309f51df6 | |||
| a8f729f5c1 | |||
| 4e8b561f70 | |||
| e6214ddc5d | |||
| 80e13abc4a | |||
| fb43abb950 | |||
| eb3e37b950 | |||
| 0a90e3b333 | |||
| 4409ed8f45 | |||
| 291a340cdd | |||
| 0667f01471 | |||
| d5847bb105 | |||
| 3d295e961e | |||
| e408398523 | |||
| 566cebfcdd | |||
| a190ae3ed6 | |||
| 17d7cefa12 | |||
| eaef0556fa | |||
| 53226eab61 | |||
| ccf558eaa7 | |||
| 0305433e46 | |||
| d2cad6c496 | |||
| 371796cce9 | |||
| d9adb49c27 | |||
| f67e4e6cb9 | |||
| 60a126024c | |||
| da8a485087 | |||
| d809c21ee1 | |||
| ca1e221b10 | |||
| 44d9c51ff9 | |||
| 15a54f4f23 | |||
| 51806f18db | |||
| 79ef7bcd1c | |||
| 5cb85ea857 | |||
| 9807db6ac0 | |||
| 565a535d22 | |||
| c5b6a8b5b9 | |||
| c75c71709f | |||
| 11976b15f9 | |||
| 847f516a65 | |||
| de410f42b8 | |||
| 0fd7caef4b | |||
| 5e919d3e19 | |||
| 9e90aa17b9 | |||
| 0a8fd993e5 | |||
| 365f71cd1d | |||
| 601346b209 | |||
| e988a8c121 | |||
| bf81a4e48b | |||
| 4a09aee177 | |||
| 00c9cc1fcd | |||
| 51a47b5ddd | |||
| 244f97a68e | |||
| 8d84a3c502 | |||
| a26b4b3965 | |||
| f2dd20cdec | |||
| 64d6cd293c | |||
| 08dc0601e8 | |||
| 9c983df7e9 | |||
| efc547f5b2 | |||
| 995ca3b40f | |||
| 864435d3f9 | |||
| 64c145cf80 | |||
| 6549253ed1 | |||
| d9565718a4 | |||
| 400c3024fd | |||
| a703175b3a | |||
| 83b122ab75 | |||
| 1e7d6ba5b1 | |||
| 4d1ad8fb38 | |||
| d544252e3e | |||
| b02b28d3e3 | |||
| d7efe1e553 | |||
| 1973564272 | |||
| 29923f6dc7 | |||
| 4a261d5ff8 | |||
| 31d425d462 | |||
| a21681a6d7 | |||
| 208019a0b9 | |||
| 54e2a54ecb | |||
| 2426ef05ec | |||
| 25b90a8724 |
@@ -104,6 +104,15 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"infra"
|
"infra"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "lincolnthedev",
|
||||||
|
"name": "i use arch btw",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/41193328?v=4",
|
||||||
|
"profile": "https://lncn.dev",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"contributorsPerLine": 7,
|
"contributorsPerLine": 7,
|
||||||
|
|||||||
@@ -1,2 +1,9 @@
|
|||||||
node_modules
|
node_modules
|
||||||
lib
|
lib
|
||||||
|
Dockerfile
|
||||||
|
Dockerfile.arm32v7
|
||||||
|
Dockerfile.arm64v8
|
||||||
|
README.md
|
||||||
|
.all-contributorsrc
|
||||||
|
env.example
|
||||||
|
.github/
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: crystallang/crystal:0.36.1-alpine
|
image: crystallang/crystal:1.0.0-alpine
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
|||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
FROM crystallang/crystal:0.36.1-alpine AS builder
|
FROM crystallang/crystal:1.0.0-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /Mango
|
WORKDIR /Mango
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
RUN apk add --no-cache yarn yaml-static sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
||||||
RUN make static || make static
|
RUN make static || make static
|
||||||
|
|
||||||
FROM library/alpine
|
FROM library/alpine
|
||||||
|
|||||||
+4
-4
@@ -2,10 +2,10 @@ FROM arm32v7/ubuntu:18.04
|
|||||||
|
|
||||||
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
||||||
|
|
||||||
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.36.1 && make deps && cd ..
|
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 1.0.0 && make deps && cd ..
|
||||||
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
|
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.8 && make && cd ..
|
||||||
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
|
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v1.0.0 && make && cd ..
|
||||||
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
|
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.5.0 && make && cd ..
|
||||||
|
|
||||||
COPY mango-arm32v7.o .
|
COPY mango-arm32v7.o .
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -2,10 +2,10 @@ FROM arm64v8/ubuntu:18.04
|
|||||||
|
|
||||||
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
||||||
|
|
||||||
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.36.1 && make deps && cd ..
|
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 1.0.0 && make deps && cd ..
|
||||||
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
|
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.8 && make && cd ..
|
||||||
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
|
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v1.0.0 && make && cd ..
|
||||||
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
|
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.5.0 && make && cd ..
|
||||||
|
|
||||||
COPY mango-arm64v8.o .
|
COPY mango-arm64v8.o .
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Mango
|
# Mango
|
||||||
|
|
||||||
[](https://www.patreon.com/hkalexling)  [](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
|
[](https://www.patreon.com/hkalexling)  [](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [](http://discord.com/invite/ezKtacCp9Q)
|
||||||
|
|
||||||
Mango is a self-hosted manga server and reader. Its features include
|
Mango is a self-hosted manga server and reader. Its features include
|
||||||
|
|
||||||
@@ -13,7 +13,6 @@ Mango is a self-hosted manga server and reader. Its features include
|
|||||||
- Supports nested folders in library
|
- Supports nested folders in library
|
||||||
- Automatically stores reading progress
|
- Automatically stores reading progress
|
||||||
- Thumbnail generation
|
- Thumbnail generation
|
||||||
- Built-in [MangaDex](https://mangadex.org/) downloader
|
|
||||||
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites
|
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites
|
||||||
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
||||||
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
||||||
@@ -52,7 +51,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
|||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
```
|
```
|
||||||
Mango - Manga Server and Web Reader. Version 0.22.0
|
Mango - Manga Server and Web Reader. Version 0.24.0
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
@@ -87,7 +86,10 @@ log_level: info
|
|||||||
upload_path: ~/mango/uploads
|
upload_path: ~/mango/uploads
|
||||||
plugin_path: ~/mango/plugins
|
plugin_path: ~/mango/plugins
|
||||||
download_timeout_seconds: 30
|
download_timeout_seconds: 30
|
||||||
page_margin: 30
|
library_cache_path: ~/mango/library.yml.gz
|
||||||
|
cache_enabled: false
|
||||||
|
cache_size_mbs: 50
|
||||||
|
cache_log_enabled: true
|
||||||
disable_login: false
|
disable_login: false
|
||||||
default_username: ""
|
default_username: ""
|
||||||
auth_proxy_header_name: ""
|
auth_proxy_header_name: ""
|
||||||
@@ -99,12 +101,12 @@ mangadex:
|
|||||||
download_queue_db_path: ~/mango/queue.db
|
download_queue_db_path: ~/mango/queue.db
|
||||||
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
|
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
|
||||||
manga_rename_rule: '{title}'
|
manga_rename_rule: '{title}'
|
||||||
subscription_update_interval_hours: 24
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
||||||
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
|
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
|
||||||
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
|
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
|
||||||
|
- By setting `cache_enabled` to `true`, you can enable an experimental feature where Mango caches library metadata to improve page load time. You can further fine-tune the feature with `cache_size_mbs` and `cache_log_enabled`.
|
||||||
|
|
||||||
### Library Structure
|
### Library Structure
|
||||||
|
|
||||||
@@ -176,6 +178,7 @@ Please check the [development guideline](https://github.com/hkalexling/Mango/wik
|
|||||||
<td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td>
|
<td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td>
|
||||||
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">💻</a></td>
|
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">💻</a></td>
|
||||||
<td align="center"><a href="https://github.com/davidkna"><img src="https://avatars.githubusercontent.com/u/835177?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David Knaack</b></sub></a><br /><a href="#infra-davidkna" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
<td align="center"><a href="https://github.com/davidkna"><img src="https://avatars.githubusercontent.com/u/835177?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David Knaack</b></sub></a><br /><a href="#infra-davidkna" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center"><a href="https://lncn.dev"><img src="https://avatars.githubusercontent.com/u/41193328?v=4?s=100" width="100px;" alt=""/><br /><sub><b>i use arch btw</b></sub></a><br /><a href="#infra-lincolnthedev" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -1,285 +0,0 @@
|
|||||||
const downloadComponent = () => {
|
|
||||||
return {
|
|
||||||
chaptersLimit: 1000,
|
|
||||||
loading: false,
|
|
||||||
addingToDownload: false,
|
|
||||||
searchAvailable: false,
|
|
||||||
searchInput: '',
|
|
||||||
data: {},
|
|
||||||
chapters: [],
|
|
||||||
mangaAry: undefined, // undefined: not searching; []: searched but no result
|
|
||||||
candidateManga: {},
|
|
||||||
langChoice: 'All',
|
|
||||||
groupChoice: 'All',
|
|
||||||
chapterRange: '',
|
|
||||||
volumeRange: '',
|
|
||||||
|
|
||||||
get languages() {
|
|
||||||
const set = new Set();
|
|
||||||
if (this.data.chapters) {
|
|
||||||
this.data.chapters.forEach(chp => {
|
|
||||||
set.add(chp.language);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const ary = [...set].sort();
|
|
||||||
ary.unshift('All');
|
|
||||||
return ary;
|
|
||||||
},
|
|
||||||
|
|
||||||
get groups() {
|
|
||||||
const set = new Set();
|
|
||||||
if (this.data.chapters) {
|
|
||||||
this.data.chapters.forEach(chp => {
|
|
||||||
Object.keys(chp.groups).forEach(g => {
|
|
||||||
set.add(g);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const ary = [...set].sort();
|
|
||||||
ary.unshift('All');
|
|
||||||
return ary;
|
|
||||||
},
|
|
||||||
|
|
||||||
init() {
|
|
||||||
const tableObserver = new MutationObserver(() => {
|
|
||||||
console.log('table mutated');
|
|
||||||
$("#selectable").selectable({
|
|
||||||
filter: 'tr'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
tableObserver.observe($('table').get(0), {
|
|
||||||
childList: true,
|
|
||||||
subtree: true
|
|
||||||
});
|
|
||||||
|
|
||||||
$.getJSON(`${base_url}api/admin/mangadex/expires`)
|
|
||||||
.done((data) => {
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.expires && data.expires > Math.floor(Date.now() / 1000))
|
|
||||||
this.searchAvailable = true;
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
filtersUpdated() {
|
|
||||||
if (!this.data.chapters)
|
|
||||||
this.chapters = [];
|
|
||||||
const filters = {
|
|
||||||
chapter: this.parseRange(this.chapterRange),
|
|
||||||
volume: this.parseRange(this.volumeRange),
|
|
||||||
lang: this.langChoice,
|
|
||||||
group: this.groupChoice
|
|
||||||
};
|
|
||||||
console.log('filters:', filters);
|
|
||||||
let _chapters = this.data.chapters.slice();
|
|
||||||
Object.entries(filters).forEach(([k, v]) => {
|
|
||||||
if (v === 'All') return;
|
|
||||||
if (k === 'group') {
|
|
||||||
_chapters = _chapters.filter(c => {
|
|
||||||
const unescaped_groups = Object.entries(c.groups).map(([g, id]) => this.unescapeHTML(g));
|
|
||||||
return unescaped_groups.indexOf(v) >= 0;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (k === 'lang') {
|
|
||||||
_chapters = _chapters.filter(c => c.language === v);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lb = parseFloat(v[0]);
|
|
||||||
const ub = parseFloat(v[1]);
|
|
||||||
if (isNaN(lb) && isNaN(ub)) return;
|
|
||||||
_chapters = _chapters.filter(c => {
|
|
||||||
const val = parseFloat(c[k]);
|
|
||||||
if (isNaN(val)) return false;
|
|
||||||
if (isNaN(lb))
|
|
||||||
return val <= ub;
|
|
||||||
else if (isNaN(ub))
|
|
||||||
return val >= lb;
|
|
||||||
else
|
|
||||||
return val >= lb && val <= ub;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
console.log('filtered chapters:', _chapters);
|
|
||||||
this.chapters = _chapters;
|
|
||||||
},
|
|
||||||
|
|
||||||
search() {
|
|
||||||
if (this.loading || this.searchInput === '') return;
|
|
||||||
this.data = {};
|
|
||||||
this.mangaAry = undefined;
|
|
||||||
|
|
||||||
var int_id = -1;
|
|
||||||
try {
|
|
||||||
const path = new URL(this.searchInput).pathname;
|
|
||||||
const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
|
|
||||||
int_id = parseInt(match[1]);
|
|
||||||
} catch (e) {
|
|
||||||
int_id = parseInt(this.searchInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isNaN(int_id) && int_id > 0) {
|
|
||||||
// The input is a positive integer. We treat it as an ID.
|
|
||||||
this.loading = true;
|
|
||||||
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
|
|
||||||
.done((data) => {
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', 'Failed to get manga info. Error: ' + data.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.data = data;
|
|
||||||
this.chapters = data.chapters;
|
|
||||||
this.mangaAry = undefined;
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (!this.searchAvailable) {
|
|
||||||
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex. If you are trying to search MangaDex with a search term, please log in to MangaDex first by going to "Admin -> Connect to MangaDex".');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search as a search term
|
|
||||||
this.loading = true;
|
|
||||||
$.getJSON(`${base_url}api/admin/mangadex/search?${$.param({
|
|
||||||
query: this.searchInput
|
|
||||||
})}`)
|
|
||||||
.done((data) => {
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to search MangaDex. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.mangaAry = data.manga;
|
|
||||||
this.data = {};
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to search MangaDex. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
parseRange(str) {
|
|
||||||
const regex = /^[\t ]*(?:(?:(<|<=|>|>=)[\t ]*([0-9]+))|(?:([0-9]+))|(?:([0-9]+)[\t ]*-[\t ]*([0-9]+))|(?:[\t ]*))[\t ]*$/m;
|
|
||||||
const matches = str.match(regex);
|
|
||||||
var num;
|
|
||||||
|
|
||||||
if (!matches) {
|
|
||||||
return [null, null];
|
|
||||||
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
|
|
||||||
// e.g., <= 30
|
|
||||||
num = parseInt(matches[2]);
|
|
||||||
if (isNaN(num)) {
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
switch (matches[1]) {
|
|
||||||
case '<':
|
|
||||||
return [null, num - 1];
|
|
||||||
case '<=':
|
|
||||||
return [null, num];
|
|
||||||
case '>':
|
|
||||||
return [num + 1, null];
|
|
||||||
case '>=':
|
|
||||||
return [num, null];
|
|
||||||
}
|
|
||||||
} else if (typeof matches[3] !== 'undefined') {
|
|
||||||
// a single number
|
|
||||||
num = parseInt(matches[3]);
|
|
||||||
if (isNaN(num)) {
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
return [num, num];
|
|
||||||
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
|
|
||||||
// e.g., 10 - 23
|
|
||||||
num = parseInt(matches[4]);
|
|
||||||
const n2 = parseInt(matches[5]);
|
|
||||||
if (isNaN(num) || isNaN(n2) || num > n2) {
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
return [num, n2];
|
|
||||||
} else {
|
|
||||||
// empty or space only
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
unescapeHTML(str) {
|
|
||||||
var elt = document.createElement("span");
|
|
||||||
elt.innerHTML = str;
|
|
||||||
return elt.innerText;
|
|
||||||
},
|
|
||||||
|
|
||||||
selectAll() {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).addClass('ui-selected');
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
clearSelection() {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).removeClass('ui-selected');
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
download() {
|
|
||||||
const selected = $('tbody > tr.ui-selected');
|
|
||||||
if (selected.length === 0) return;
|
|
||||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
|
||||||
const ids = selected.map((i, e) => {
|
|
||||||
return parseInt($(e).find('td').first().text());
|
|
||||||
}).get();
|
|
||||||
const chapters = this.chapters.filter(c => ids.indexOf(c.id) >= 0);
|
|
||||||
console.log(ids);
|
|
||||||
this.addingToDownload = true;
|
|
||||||
$.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: `${base_url}api/admin/mangadex/download`,
|
|
||||||
data: JSON.stringify({
|
|
||||||
chapters: chapters
|
|
||||||
}),
|
|
||||||
contentType: "application/json",
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
console.log(data);
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const successCount = parseInt(data.success);
|
|
||||||
const failCount = parseInt(data.fail);
|
|
||||||
alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`);
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
this.addingToDownload = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
chooseManga(manga) {
|
|
||||||
this.candidateManga = manga;
|
|
||||||
UIkit.modal($('#modal').get(0)).show();
|
|
||||||
},
|
|
||||||
|
|
||||||
confirmManga(id) {
|
|
||||||
UIkit.modal($('#modal').get(0)).hide();
|
|
||||||
this.searchInput = id;
|
|
||||||
this.search();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
const component = () => {
|
|
||||||
return {
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
expires: undefined,
|
|
||||||
loading: true,
|
|
||||||
loggingIn: false,
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.loading = true;
|
|
||||||
$.ajax({
|
|
||||||
type: 'GET',
|
|
||||||
url: `${base_url}api/admin/mangadex/expires`,
|
|
||||||
contentType: "application/json",
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
console.log(data);
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to retrieve MangaDex token status. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.expires = data.expires;
|
|
||||||
this.loading = false;
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to retrieve MangaDex token status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
login() {
|
|
||||||
if (!(this.username && this.password)) return;
|
|
||||||
this.loggingIn = true;
|
|
||||||
$.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: `${base_url}api/admin/mangadex/login`,
|
|
||||||
contentType: "application/json",
|
|
||||||
dataType: 'json',
|
|
||||||
data: JSON.stringify({
|
|
||||||
username: this.username,
|
|
||||||
password: this.password
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
console.log(data);
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to log in. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.expires = data.expires;
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to log in. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
this.loggingIn = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
get expired() {
|
|
||||||
return this.expires && moment().diff(moment.unix(this.expires)) > 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
+132
-319
@@ -1,326 +1,139 @@
|
|||||||
const component = () => {
|
const loadPlugin = id => {
|
||||||
return {
|
localStorage.setItem('plugin', id);
|
||||||
plugins: [],
|
const url = `${location.protocol}//${location.host}${location.pathname}`;
|
||||||
info: undefined,
|
const newURL = `${url}?${$.param({
|
||||||
pid: undefined,
|
plugin: id
|
||||||
chapters: undefined, // undefined: not searched yet, []: empty
|
})}`;
|
||||||
manga: undefined, // undefined: not searched yet, []: empty
|
window.location.href = newURL;
|
||||||
allChapters: [],
|
};
|
||||||
query: '',
|
|
||||||
mangaTitle: '',
|
|
||||||
searching: false,
|
|
||||||
adding: false,
|
|
||||||
sortOptions: [],
|
|
||||||
showFilters: false,
|
|
||||||
appliedFilters: [],
|
|
||||||
chaptersLimit: 500,
|
|
||||||
listManga: false,
|
|
||||||
|
|
||||||
init() {
|
$(() => {
|
||||||
const tableObserver = new MutationObserver(() => {
|
var storedID = localStorage.getItem('plugin');
|
||||||
console.log('table mutated');
|
if (storedID && storedID !== pid) {
|
||||||
$('#selectable').selectable({
|
loadPlugin(storedID);
|
||||||
filter: 'tr'
|
} else {
|
||||||
});
|
$('#controls').removeAttr('hidden');
|
||||||
});
|
}
|
||||||
tableObserver.observe($('table').get(0), {
|
|
||||||
childList: true,
|
|
||||||
subtree: true
|
|
||||||
});
|
|
||||||
fetch(`${base_url}api/admin/plugin`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
if (!data.success)
|
|
||||||
throw new Error(data.error);
|
|
||||||
this.plugins = data.plugins;
|
|
||||||
|
|
||||||
const pid = localStorage.getItem('plugin');
|
$('#search-input').keypress(event => {
|
||||||
if (pid && this.plugins.map(p => p.id).includes(pid))
|
if (event.which === 13) {
|
||||||
return this.loadPlugin(pid);
|
search();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$('#plugin-select').val(pid);
|
||||||
|
$('#plugin-select').change(() => {
|
||||||
|
const id = $('#plugin-select').val();
|
||||||
|
loadPlugin(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (this.plugins.length > 0)
|
let mangaTitle = "";
|
||||||
this.loadPlugin(this.plugins[0].id);
|
let searching = false;
|
||||||
})
|
const search = () => {
|
||||||
.catch(e => {
|
if (searching)
|
||||||
alert('danger', `Failed to list the available plugins. Error: ${e}`);
|
return;
|
||||||
});
|
|
||||||
},
|
|
||||||
loadPlugin(pid) {
|
|
||||||
fetch(`${base_url}api/admin/plugin/info?${new URLSearchParams({
|
|
||||||
plugin: pid
|
|
||||||
})}`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
if (!data.success)
|
|
||||||
throw new Error(data.error);
|
|
||||||
this.info = data.info;
|
|
||||||
this.pid = pid;
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
alert('danger', `Failed to get plugin metadata. Error: ${e}`);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
pluginChanged() {
|
|
||||||
this.loadPlugin(this.pid);
|
|
||||||
localStorage.setItem('plugin', this.pid);
|
|
||||||
},
|
|
||||||
get chapterKeys() {
|
|
||||||
if (this.allChapters.length < 1) return [];
|
|
||||||
return Object.keys(this.allChapters[0]).filter(k => !['manga_title'].includes(k));
|
|
||||||
},
|
|
||||||
searchChapters(query) {
|
|
||||||
this.searching = true;
|
|
||||||
this.allChapters = [];
|
|
||||||
this.chapters = undefined;
|
|
||||||
this.listManga = false;
|
|
||||||
fetch(`${base_url}api/admin/plugin/list?${new URLSearchParams({
|
|
||||||
plugin: this.pid,
|
|
||||||
query: query
|
|
||||||
})}`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
if (!data.success)
|
|
||||||
throw new Error(data.error);
|
|
||||||
try {
|
|
||||||
this.mangaTitle = data.chapters[0].manga_title;
|
|
||||||
if (!this.mangaTitle) throw new Error();
|
|
||||||
} catch (e) {
|
|
||||||
this.mangaTitle = data.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.allChapters = data.chapters;
|
const query = $.param({
|
||||||
this.chapters = data.chapters;
|
query: $('#search-input').val(),
|
||||||
})
|
plugin: pid
|
||||||
.catch(e => {
|
});
|
||||||
alert('danger', `Failed to list chapters. Error: ${e}`);
|
$.ajax({
|
||||||
})
|
type: 'GET',
|
||||||
.finally(() => {
|
url: `${base_url}api/admin/plugin/list?${query}`,
|
||||||
this.searching = false;
|
contentType: "application/json",
|
||||||
});
|
dataType: 'json'
|
||||||
},
|
})
|
||||||
searchManga() {
|
.done(data => {
|
||||||
this.searching = true;
|
console.log(data);
|
||||||
this.allChapters = [];
|
if (data.error) {
|
||||||
this.chapters = undefined;
|
alert('danger', `Search failed. Error: ${data.error}`);
|
||||||
this.manga = undefined;
|
|
||||||
fetch(`${base_url}api/admin/plugin/search?${new URLSearchParams({
|
|
||||||
plugin: this.pid,
|
|
||||||
query: this.query
|
|
||||||
})}`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
if (!data.success)
|
|
||||||
throw new Error(data.error);
|
|
||||||
this.manga = data.manga;
|
|
||||||
this.listManga = true;
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
alert('danger', `Search failed. Error: ${e}`);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.searching = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
search() {
|
|
||||||
this.manga = undefined;
|
|
||||||
if (this.info.version === 1) {
|
|
||||||
this.searchChapters(this.query);
|
|
||||||
} else {
|
|
||||||
this.searchManga();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selectAll() {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).addClass('ui-selected');
|
|
||||||
});
|
|
||||||
},
|
|
||||||
clearSelection() {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).removeClass('ui-selected');
|
|
||||||
});
|
|
||||||
},
|
|
||||||
download() {
|
|
||||||
const selected = $('tbody > tr.ui-selected').get();
|
|
||||||
if (selected.length === 0) return;
|
|
||||||
|
|
||||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
|
||||||
const ids = selected.map(e => e.id);
|
|
||||||
const chapters = this.chapters.filter(c => ids.includes(c.id));
|
|
||||||
console.log(chapters);
|
|
||||||
this.adding = true;
|
|
||||||
fetch(`${base_url}api/admin/plugin/download`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
chapters,
|
|
||||||
plugin: this.pid,
|
|
||||||
title: this.mangaTitle
|
|
||||||
}),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
if (!data.success)
|
|
||||||
throw new Error(data.error);
|
|
||||||
const successCount = parseInt(data.success);
|
|
||||||
const failCount = parseInt(data.fail);
|
|
||||||
alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`);
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: ${e}`);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.adding = false;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
},
|
|
||||||
thClicked(event) {
|
|
||||||
const idx = parseInt(event.currentTarget.id.split('-')[1]);
|
|
||||||
if (idx === undefined || isNaN(idx)) return;
|
|
||||||
const curOption = this.sortOptions[idx];
|
|
||||||
let option;
|
|
||||||
this.sortOptions = [];
|
|
||||||
switch (curOption) {
|
|
||||||
case 1:
|
|
||||||
option = -1;
|
|
||||||
break;
|
|
||||||
case -1:
|
|
||||||
option = 0;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
option = 1;
|
|
||||||
}
|
|
||||||
this.sortOptions[idx] = option;
|
|
||||||
this.sort(this.chapterKeys[idx], option)
|
|
||||||
},
|
|
||||||
// Returns an array of filtered but unsorted chapters. Useful when
|
|
||||||
// reseting the sort options.
|
|
||||||
get filteredChapters() {
|
|
||||||
let ary = this.allChapters.slice();
|
|
||||||
|
|
||||||
console.log('initial size:', ary.length);
|
|
||||||
for (let filter of this.appliedFilters) {
|
|
||||||
if (!filter.value) continue;
|
|
||||||
if (filter.type === 'array' && filter.value === 'all') continue;
|
|
||||||
|
|
||||||
console.log('applying filter:', filter);
|
|
||||||
|
|
||||||
if (filter.type === 'string') {
|
|
||||||
ary = ary.filter(ch => ch[filter.key].toLowerCase().includes(filter.value.toLowerCase()));
|
|
||||||
}
|
|
||||||
if (filter.type === 'number-min') {
|
|
||||||
ary = ary.filter(ch => Number(ch[filter.key]) >= Number(filter.value));
|
|
||||||
}
|
|
||||||
if (filter.type === 'number-max') {
|
|
||||||
ary = ary.filter(ch => Number(ch[filter.key]) <= Number(filter.value));
|
|
||||||
}
|
|
||||||
if (filter.type === 'date-min') {
|
|
||||||
ary = ary.filter(ch => this.parseDate(ch[filter.key]) >= this.parseDate(filter.value));
|
|
||||||
}
|
|
||||||
if (filter.type === 'date-max') {
|
|
||||||
ary = ary.filter(ch => this.parseDate(ch[filter.key]) <= this.parseDate(filter.value));
|
|
||||||
}
|
|
||||||
if (filter.type === 'array') {
|
|
||||||
ary = ary.filter(ch => ch[filter.key].map(s => typeof s === 'string' ? s.toLowerCase() : s).includes(filter.value.toLowerCase()));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('filtered size:', ary.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ary;
|
|
||||||
},
|
|
||||||
// option:
|
|
||||||
// - 1: asending
|
|
||||||
// - -1: desending
|
|
||||||
// - 0: unsorted
|
|
||||||
sort(key, option) {
|
|
||||||
if (option === 0) {
|
|
||||||
this.chapters = this.filteredChapters;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
mangaTitle = data.title;
|
||||||
this.chapters = this.filteredChapters.sort((a, b) => {
|
$('#title-text').text(data.title);
|
||||||
const comp = this.compare(a[key], b[key]);
|
buildTable(data.chapters);
|
||||||
return option < 0 ? comp * -1 : comp;
|
})
|
||||||
});
|
.fail((jqXHR, status) => {
|
||||||
},
|
alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
compare(a, b) {
|
})
|
||||||
if (a === b) return 0;
|
.always(() => {});
|
||||||
|
};
|
||||||
// try numbers
|
|
||||||
// this must come before the date checks, because any integer would
|
const buildTable = (chapters) => {
|
||||||
// also be parsed as a date.
|
$('#table').attr('hidden', '');
|
||||||
if (!isNaN(a) && !isNaN(b))
|
$('table').empty();
|
||||||
return Number(a) - Number(b);
|
|
||||||
|
const keys = Object.keys(chapters[0]).map(k => `<th>${k}</th>`).join('');
|
||||||
// try dates
|
const thead = `<thead><tr>${keys}</tr></thead>`;
|
||||||
if (!isNaN(this.parseDate(a)) && !isNaN(this.parseDate(b)))
|
$('table').append(thead);
|
||||||
return this.parseDate(a) - this.parseDate(b);
|
|
||||||
|
const rows = chapters.map(ch => {
|
||||||
const preprocessString = (val) => {
|
const tds = Object.values(ch).map(v => `<td>${v}</td>`).join('');
|
||||||
if (typeof val !== 'string') return val;
|
return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
|
||||||
return val.toLowerCase().replace(/\s\s/g, ' ').trim();
|
});
|
||||||
};
|
const tbody = `<tbody id="selectable">${rows}</tbody>`;
|
||||||
|
$('table').append(tbody);
|
||||||
return preprocessString(a) > preprocessString(b) ? 1 : -1;
|
|
||||||
},
|
$('#selectable').selectable({
|
||||||
fieldType(values) {
|
filter: 'tr'
|
||||||
if (values.every(v => !isNaN(v))) return 'number'; // display input for number range
|
});
|
||||||
if (values.every(v => !isNaN(this.parseDate(v)))) return 'date'; // display input for date range
|
|
||||||
if (values.every(v => Array.isArray(v))) return 'array'; // display input for contains
|
$('#table table').tablesorter();
|
||||||
return 'string'; // display input for string searching.
|
$('#table').removeAttr('hidden');
|
||||||
// for the last two, if the number of options is small enough (say < 50), display a multi-select2
|
};
|
||||||
},
|
|
||||||
get filters() {
|
const selectAll = () => {
|
||||||
if (this.allChapters.length < 1) return [];
|
$('tbody > tr').each((i, e) => {
|
||||||
const keys = Object.keys(this.allChapters[0]).filter(k => !['manga_title', 'id'].includes(k));
|
$(e).addClass('ui-selected');
|
||||||
return keys.map(k => {
|
});
|
||||||
let values = this.allChapters.map(c => c[k]);
|
};
|
||||||
const type = this.fieldType(values);
|
|
||||||
|
const unselect = () => {
|
||||||
if (type === 'array') {
|
$('tbody > tr').each((i, e) => {
|
||||||
// if the type is an array, return the list of available elements
|
$(e).removeClass('ui-selected');
|
||||||
// example: an array of groups or authors
|
});
|
||||||
values = Array.from(new Set(values.flat().map(v => {
|
};
|
||||||
if (typeof v === 'string') return v.toLowerCase();
|
|
||||||
})));
|
const download = () => {
|
||||||
}
|
const selected = $('tbody > tr.ui-selected');
|
||||||
|
if (selected.length === 0) return;
|
||||||
return {
|
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||||
key: k,
|
$('#download-btn').attr('hidden', '');
|
||||||
type: type,
|
$('#download-spinner').removeAttr('hidden');
|
||||||
values: values
|
const chapters = selected.map((i, e) => {
|
||||||
};
|
return {
|
||||||
});
|
id: $(e).attr('data-id'),
|
||||||
},
|
title: $(e).attr('data-title')
|
||||||
applyFilters() {
|
}
|
||||||
const values = $('#filter-form input, #filter-form select')
|
}).get();
|
||||||
.get()
|
console.log(chapters);
|
||||||
.map(i => ({
|
$.ajax({
|
||||||
key: i.getAttribute('data-filter-key'),
|
type: 'POST',
|
||||||
value: i.value.trim(),
|
url: base_url + 'api/admin/plugin/download',
|
||||||
type: i.getAttribute('data-filter-type')
|
data: JSON.stringify({
|
||||||
}));
|
plugin: pid,
|
||||||
this.appliedFilters = values;
|
chapters: chapters,
|
||||||
this.chapters = this.filteredChapters;
|
title: mangaTitle
|
||||||
},
|
}),
|
||||||
clearFilters() {
|
contentType: "application/json",
|
||||||
$('#filter-form input').get().forEach(i => i.value = '');
|
dataType: 'json'
|
||||||
this.appliedFilters = [];
|
})
|
||||||
this.chapters = this.filteredChapters;
|
.done(data => {
|
||||||
},
|
console.log(data);
|
||||||
mangaSelected(event) {
|
if (data.error) {
|
||||||
const mid = event.currentTarget.getAttribute('data-id');
|
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
||||||
this.searchChapters(mid);
|
return;
|
||||||
},
|
}
|
||||||
parseDate(str) {
|
const successCount = parseInt(data.success);
|
||||||
const regex = /([0-9]+[/\-,\ ][0-9]+[/\-,\ ][0-9]+)|([A-Za-z]+)[/\-,\ ]+[0-9]+(st|nd|rd|th)?[/\-,\ ]+[0-9]+/g;
|
const failCount = parseInt(data.fail);
|
||||||
// Basic sanity check to make sure it's an actual date.
|
alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`);
|
||||||
// We need this because Date.parse thinks 'Chapter 1' is a date.
|
})
|
||||||
if (!regex.test(str))
|
.fail((jqXHR, status) => {
|
||||||
return NaN;
|
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
return Date.parse(str);
|
})
|
||||||
}
|
.always(() => {
|
||||||
};
|
$('#download-spinner').attr('hidden', '');
|
||||||
|
$('#download-btn').removeAttr('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
+37
-5
@@ -6,11 +6,13 @@ const readerComponent = () => {
|
|||||||
alertClass: 'uk-alert-primary',
|
alertClass: 'uk-alert-primary',
|
||||||
items: [],
|
items: [],
|
||||||
curItem: {},
|
curItem: {},
|
||||||
|
enableFlipAnimation: true,
|
||||||
flipAnimation: null,
|
flipAnimation: null,
|
||||||
longPages: false,
|
longPages: false,
|
||||||
lastSavedPage: page,
|
lastSavedPage: page,
|
||||||
selectedIndex: 0, // 0: not selected; 1: the first page
|
selectedIndex: 0, // 0: not selected; 1: the first page
|
||||||
margin: 30,
|
margin: 30,
|
||||||
|
preloadLookahead: 3,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the component by fetching the page dimensions
|
* Initialize the component by fetching the page dimensions
|
||||||
@@ -52,6 +54,16 @@ const readerComponent = () => {
|
|||||||
if (savedMargin) {
|
if (savedMargin) {
|
||||||
this.margin = savedMargin;
|
this.margin = savedMargin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preload Images
|
||||||
|
this.preloadLookahead = +(localStorage.getItem('preloadLookahead') ?? 3);
|
||||||
|
const limit = Math.min(page + this.preloadLookahead, this.items.length + 1);
|
||||||
|
for (let idx = page + 1; idx <= limit; idx++) {
|
||||||
|
this.preloadImage(this.items[idx - 1].url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedFlipAnimation = localStorage.getItem('enableFlipAnimation');
|
||||||
|
this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true';
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
const errMsg = `Failed to get the page dimensions. ${e}`;
|
const errMsg = `Failed to get the page dimensions. ${e}`;
|
||||||
@@ -60,6 +72,12 @@ const readerComponent = () => {
|
|||||||
this.msg = errMsg;
|
this.msg = errMsg;
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Preload an image, which is expected to be cached
|
||||||
|
*/
|
||||||
|
preloadImage(url) {
|
||||||
|
(new Image()).src = url;
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Handles the `change` event for the page selector
|
* Handles the `change` event for the page selector
|
||||||
*/
|
*/
|
||||||
@@ -111,12 +129,18 @@ const readerComponent = () => {
|
|||||||
|
|
||||||
if (newIdx <= 0 || newIdx > this.items.length) return;
|
if (newIdx <= 0 || newIdx > this.items.length) return;
|
||||||
|
|
||||||
|
if (newIdx + this.preloadLookahead < this.items.length + 1) {
|
||||||
|
this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url);
|
||||||
|
}
|
||||||
|
|
||||||
this.toPage(newIdx);
|
this.toPage(newIdx);
|
||||||
|
|
||||||
if (isNext)
|
if (this.enableFlipAnimation) {
|
||||||
this.flipAnimation = 'right';
|
if (isNext)
|
||||||
else
|
this.flipAnimation = 'right';
|
||||||
this.flipAnimation = 'left';
|
else
|
||||||
|
this.flipAnimation = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.flipAnimation = null;
|
this.flipAnimation = null;
|
||||||
@@ -287,6 +311,14 @@ const readerComponent = () => {
|
|||||||
marginChanged() {
|
marginChanged() {
|
||||||
localStorage.setItem('margin', this.margin);
|
localStorage.setItem('margin', this.margin);
|
||||||
this.toPage(this.selectedIndex);
|
this.toPage(this.selectedIndex);
|
||||||
}
|
},
|
||||||
|
|
||||||
|
preloadLookaheadChanged() {
|
||||||
|
localStorage.setItem('preloadLookahead', this.preloadLookahead);
|
||||||
|
},
|
||||||
|
|
||||||
|
enableFlipAnimationChanged() {
|
||||||
|
localStorage.setItem('enableFlipAnimation', this.enableFlipAnimation);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-23
@@ -2,31 +2,31 @@ version: 2.0
|
|||||||
shards:
|
shards:
|
||||||
ameba:
|
ameba:
|
||||||
git: https://github.com/crystal-ameba/ameba.git
|
git: https://github.com/crystal-ameba/ameba.git
|
||||||
version: 0.14.0
|
version: 0.14.3
|
||||||
|
|
||||||
archive:
|
archive:
|
||||||
git: https://github.com/hkalexling/archive.cr.git
|
git: https://github.com/hkalexling/archive.cr.git
|
||||||
version: 0.4.0
|
version: 0.5.0
|
||||||
|
|
||||||
baked_file_system:
|
baked_file_system:
|
||||||
git: https://github.com/schovi/baked_file_system.git
|
git: https://github.com/schovi/baked_file_system.git
|
||||||
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
|
version: 0.10.0
|
||||||
|
|
||||||
clim:
|
clim:
|
||||||
git: https://github.com/at-grandpa/clim.git
|
git: https://github.com/at-grandpa/clim.git
|
||||||
version: 0.12.0
|
version: 0.17.1
|
||||||
|
|
||||||
db:
|
db:
|
||||||
git: https://github.com/crystal-lang/crystal-db.git
|
git: https://github.com/crystal-lang/crystal-db.git
|
||||||
version: 0.9.0
|
version: 0.10.1
|
||||||
|
|
||||||
duktape:
|
duktape:
|
||||||
git: https://github.com/jessedoyle/duktape.cr.git
|
git: https://github.com/jessedoyle/duktape.cr.git
|
||||||
version: 0.20.0
|
version: 1.0.0
|
||||||
|
|
||||||
exception_page:
|
exception_page:
|
||||||
git: https://github.com/crystal-loot/exception_page.git
|
git: https://github.com/crystal-loot/exception_page.git
|
||||||
version: 0.1.4
|
version: 0.1.5
|
||||||
|
|
||||||
http_proxy:
|
http_proxy:
|
||||||
git: https://github.com/mamantoha/http_proxy.git
|
git: https://github.com/mamantoha/http_proxy.git
|
||||||
@@ -34,49 +34,45 @@ shards:
|
|||||||
|
|
||||||
image_size:
|
image_size:
|
||||||
git: https://github.com/hkalexling/image_size.cr.git
|
git: https://github.com/hkalexling/image_size.cr.git
|
||||||
version: 0.4.0
|
version: 0.5.0
|
||||||
|
|
||||||
kemal:
|
kemal:
|
||||||
git: https://github.com/kemalcr/kemal.git
|
git: https://github.com/kemalcr/kemal.git
|
||||||
version: 0.27.0
|
version: 1.0.0
|
||||||
|
|
||||||
kemal-session:
|
kemal-session:
|
||||||
git: https://github.com/kemalcr/kemal-session.git
|
git: https://github.com/kemalcr/kemal-session.git
|
||||||
version: 0.13.0
|
version: 1.0.0
|
||||||
|
|
||||||
kilt:
|
kilt:
|
||||||
git: https://github.com/jeromegn/kilt.git
|
git: https://github.com/jeromegn/kilt.git
|
||||||
version: 0.4.0
|
version: 0.4.1
|
||||||
|
|
||||||
koa:
|
koa:
|
||||||
git: https://github.com/hkalexling/koa.git
|
git: https://github.com/hkalexling/koa.git
|
||||||
version: 0.7.0
|
version: 0.8.0
|
||||||
|
|
||||||
mangadex:
|
|
||||||
git: https://github.com/hkalexling/mangadex.git
|
|
||||||
version: 0.9.0+git.commit.a8e5deb3e6f882f5bc0f4de66e0f6c20aa98a8a6
|
|
||||||
|
|
||||||
mg:
|
mg:
|
||||||
git: https://github.com/hkalexling/mg.git
|
git: https://github.com/hkalexling/mg.git
|
||||||
version: 0.3.0+git.commit.a19417abf03eece80039f89569926cff1ce3a1a3
|
version: 0.5.0+git.commit.697e46e27cde8c3969346e228e372db2455a6264
|
||||||
|
|
||||||
myhtml:
|
myhtml:
|
||||||
git: https://github.com/kostya/myhtml.git
|
git: https://github.com/kostya/myhtml.git
|
||||||
version: 1.5.1
|
version: 1.5.8
|
||||||
|
|
||||||
open_api:
|
open_api:
|
||||||
git: https://github.com/jreinert/open_api.cr.git
|
git: https://github.com/hkalexling/open_api.cr.git
|
||||||
version: 1.2.1+git.commit.95e4df2ca10b1fe88b8b35c62a18b06a10267b6c
|
version: 1.2.1+git.commit.1d3c55dd5534c6b0af18964d031858a08515553a
|
||||||
|
|
||||||
radix:
|
radix:
|
||||||
git: https://github.com/luislavena/radix.git
|
git: https://github.com/luislavena/radix.git
|
||||||
version: 0.3.9
|
version: 0.4.1
|
||||||
|
|
||||||
sqlite3:
|
sqlite3:
|
||||||
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
||||||
version: 0.16.0
|
version: 0.18.0
|
||||||
|
|
||||||
tallboy:
|
tallboy:
|
||||||
git: https://github.com/epoch/tallboy.git
|
git: https://github.com/epoch/tallboy.git
|
||||||
version: 0.9.3
|
version: 0.9.3+git.commit.9be1510bb0391c95e92f1b288f3afb429a73caa6
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: mango
|
name: mango
|
||||||
version: 0.22.0
|
version: 0.24.0
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Alex Ling <hkalexling@gmail.com>
|
- Alex Ling <hkalexling@gmail.com>
|
||||||
@@ -8,7 +8,7 @@ targets:
|
|||||||
mango:
|
mango:
|
||||||
main: src/mango.cr
|
main: src/mango.cr
|
||||||
|
|
||||||
crystal: 0.36.1
|
crystal: 1.0.0
|
||||||
|
|
||||||
license: MIT
|
license: MIT
|
||||||
|
|
||||||
@@ -21,7 +21,6 @@ dependencies:
|
|||||||
github: crystal-lang/crystal-sqlite3
|
github: crystal-lang/crystal-sqlite3
|
||||||
baked_file_system:
|
baked_file_system:
|
||||||
github: schovi/baked_file_system
|
github: schovi/baked_file_system
|
||||||
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
|
|
||||||
archive:
|
archive:
|
||||||
github: hkalexling/archive.cr
|
github: hkalexling/archive.cr
|
||||||
ameba:
|
ameba:
|
||||||
@@ -30,7 +29,6 @@ dependencies:
|
|||||||
github: at-grandpa/clim
|
github: at-grandpa/clim
|
||||||
duktape:
|
duktape:
|
||||||
github: jessedoyle/duktape.cr
|
github: jessedoyle/duktape.cr
|
||||||
version: ~> 0.20.0
|
|
||||||
myhtml:
|
myhtml:
|
||||||
github: kostya/myhtml
|
github: kostya/myhtml
|
||||||
http_proxy:
|
http_proxy:
|
||||||
@@ -41,7 +39,6 @@ dependencies:
|
|||||||
github: hkalexling/koa
|
github: hkalexling/koa
|
||||||
tallboy:
|
tallboy:
|
||||||
github: epoch/tallboy
|
github: epoch/tallboy
|
||||||
|
branch: master
|
||||||
mg:
|
mg:
|
||||||
github: hkalexling/mg
|
github: hkalexling/mg
|
||||||
mangadex:
|
|
||||||
github: hkalexling/mangadex
|
|
||||||
|
|||||||
@@ -61,3 +61,13 @@ describe "chapter_sort" do
|
|||||||
end.should eq ary
|
end.should eq ary
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "sanitize_filename" do
|
||||||
|
it "returns a random string for empty sanitized string" do
|
||||||
|
sanitize_filename("..").should_not eq sanitize_filename("..")
|
||||||
|
end
|
||||||
|
it "sanitizes correctly" do
|
||||||
|
sanitize_filename(".. \n\v.\rマンゴー/|*()<[1/2] 3.14 hello world ")
|
||||||
|
.should eq "マンゴー_()[1_2] 3.14 hello world"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class Config
|
|||||||
property session_secret : String = "mango-session-secret"
|
property session_secret : String = "mango-session-secret"
|
||||||
property library_path : String = File.expand_path "~/mango/library",
|
property library_path : String = File.expand_path "~/mango/library",
|
||||||
home: true
|
home: true
|
||||||
|
property library_cache_path = File.expand_path "~/mango/library.yml.gz",
|
||||||
|
home: true
|
||||||
property db_path : String = File.expand_path "~/mango/mango.db", home: true
|
property db_path : String = File.expand_path "~/mango/mango.db", home: true
|
||||||
property scan_interval_minutes : Int32 = 5
|
property scan_interval_minutes : Int32 = 5
|
||||||
property thumbnail_generation_interval_hours : Int32 = 24
|
property thumbnail_generation_interval_hours : Int32 = 24
|
||||||
@@ -20,6 +22,9 @@ class Config
|
|||||||
property plugin_path : String = File.expand_path "~/mango/plugins",
|
property plugin_path : String = File.expand_path "~/mango/plugins",
|
||||||
home: true
|
home: true
|
||||||
property download_timeout_seconds : Int32 = 30
|
property download_timeout_seconds : Int32 = 30
|
||||||
|
property cache_enabled = false
|
||||||
|
property cache_size_mbs = 50
|
||||||
|
property cache_log_enabled = true
|
||||||
property disable_login = false
|
property disable_login = false
|
||||||
property default_username = ""
|
property default_username = ""
|
||||||
property auth_proxy_header_name = ""
|
property auth_proxy_header_name = ""
|
||||||
|
|||||||
@@ -54,8 +54,9 @@ class AuthHandler < Kemal::Handler
|
|||||||
end
|
end
|
||||||
|
|
||||||
def call(env)
|
def call(env)
|
||||||
# Skip all authentication if requesting /login, /logout, or a static file
|
# Skip all authentication if requesting /login, /logout, /api/login,
|
||||||
if request_path_startswith(env, ["/login", "/logout"]) ||
|
# or a static file
|
||||||
|
if request_path_startswith(env, ["/login", "/logout", "/api/login"]) ||
|
||||||
requesting_static_file env
|
requesting_static_file env
|
||||||
return call_next(env)
|
return call_next(env)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
require "digest"
|
||||||
|
|
||||||
|
require "./entry"
|
||||||
|
require "./types"
|
||||||
|
|
||||||
|
# Base class for an entry in the LRU cache.
|
||||||
|
# There are two ways to use it:
|
||||||
|
# 1. Use it as it is by instantiating with the appropriate `SaveT` and
|
||||||
|
# `ReturnT`. Note that in this case, `SaveT` and `ReturnT` must be the
|
||||||
|
# same type. That is, the input value will be stored as it is without
|
||||||
|
# any transformation.
|
||||||
|
# 2. You can also subclass it and provide custom implementations for
|
||||||
|
# `to_save_t` and `to_return_t`. This allows you to transform and store
|
||||||
|
# the input value to a different type. See `SortedEntriesCacheEntry` as
|
||||||
|
# an example.
|
||||||
|
private class CacheEntry(SaveT, ReturnT)
|
||||||
|
getter key : String, atime : Time
|
||||||
|
|
||||||
|
@value : SaveT
|
||||||
|
|
||||||
|
def initialize(@key : String, value : ReturnT)
|
||||||
|
@atime = @ctime = Time.utc
|
||||||
|
@value = self.class.to_save_t value
|
||||||
|
end
|
||||||
|
|
||||||
|
def value
|
||||||
|
@atime = Time.utc
|
||||||
|
self.class.to_return_t @value
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.to_save_t(value : ReturnT)
|
||||||
|
value
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.to_return_t(value : SaveT)
|
||||||
|
value
|
||||||
|
end
|
||||||
|
|
||||||
|
def instance_size
|
||||||
|
instance_sizeof(CacheEntry(SaveT, ReturnT)) + # sizeof itself
|
||||||
|
instance_sizeof(String) + @key.bytesize + # allocated memory for @key
|
||||||
|
@value.instance_size
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class SortedEntriesCacheEntry < CacheEntry(Array(String), Array(Entry))
|
||||||
|
def self.to_save_t(value : Array(Entry))
|
||||||
|
value.map &.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.to_return_t(value : Array(String))
|
||||||
|
ids_to_entries value
|
||||||
|
end
|
||||||
|
|
||||||
|
private def self.ids_to_entries(ids : Array(String))
|
||||||
|
e_map = Library.default.deep_entries.to_h { |entry| {entry.id, entry} }
|
||||||
|
entries = [] of Entry
|
||||||
|
begin
|
||||||
|
ids.each do |id|
|
||||||
|
entries << e_map[id]
|
||||||
|
end
|
||||||
|
return entries if ids.size == entries.size
|
||||||
|
rescue
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def instance_size
|
||||||
|
instance_sizeof(SortedEntriesCacheEntry) + # sizeof itself
|
||||||
|
instance_sizeof(String) + @key.bytesize + # allocated memory for @key
|
||||||
|
@value.size * (instance_sizeof(String) + sizeof(String)) +
|
||||||
|
@value.sum(&.bytesize) # elements in Array(String)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.gen_key(book_id : String, username : String,
|
||||||
|
entries : Array(Entry), opt : SortOptions?)
|
||||||
|
entries_sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
|
||||||
|
user_context = opt && opt.method == SortMethod::Progress ? username : ""
|
||||||
|
sig = Digest::SHA1.hexdigest (book_id + entries_sig + user_context +
|
||||||
|
(opt ? opt.to_tuple.to_s : "nil"))
|
||||||
|
"#{sig}:sorted_entries"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class String
|
||||||
|
def instance_size
|
||||||
|
instance_sizeof(String) + bytesize
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
struct Tuple(*T)
|
||||||
|
def instance_size
|
||||||
|
sizeof(T) + # total size of non-reference types
|
||||||
|
self.sum do |e|
|
||||||
|
next 0 unless e.is_a? Reference
|
||||||
|
if e.responds_to? :instance_size
|
||||||
|
e.instance_size
|
||||||
|
else
|
||||||
|
instance_sizeof(typeof(e))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
alias CacheableType = Array(Entry) | String | Tuple(String, Int32)
|
||||||
|
alias CacheEntryType = SortedEntriesCacheEntry |
|
||||||
|
CacheEntry(String, String) |
|
||||||
|
CacheEntry(Tuple(String, Int32), Tuple(String, Int32))
|
||||||
|
|
||||||
|
def generate_cache_entry(key : String, value : CacheableType)
|
||||||
|
if value.is_a? Array(Entry)
|
||||||
|
SortedEntriesCacheEntry.new key, value
|
||||||
|
else
|
||||||
|
CacheEntry(typeof(value), typeof(value)).new key, value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# LRU Cache
|
||||||
|
class LRUCache
|
||||||
|
@@limit : Int128 = Int128.new 0
|
||||||
|
@@should_log = true
|
||||||
|
# key => entry
|
||||||
|
@@cache = {} of String => CacheEntryType
|
||||||
|
|
||||||
|
def self.enabled
|
||||||
|
Config.current.cache_enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.init
|
||||||
|
cache_size = Config.current.cache_size_mbs
|
||||||
|
@@limit = Int128.new cache_size * 1024 * 1024 if enabled
|
||||||
|
@@should_log = Config.current.cache_log_enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.get(key : String)
|
||||||
|
return unless enabled
|
||||||
|
entry = @@cache[key]?
|
||||||
|
if @@should_log
|
||||||
|
Logger.debug "LRUCache #{entry.nil? ? "miss" : "hit"} #{key}"
|
||||||
|
end
|
||||||
|
return entry.value unless entry.nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.set(cache_entry : CacheEntryType)
|
||||||
|
return unless enabled
|
||||||
|
key = cache_entry.key
|
||||||
|
@@cache[key] = cache_entry
|
||||||
|
Logger.debug "LRUCache cached #{key}" if @@should_log
|
||||||
|
remove_least_recent_access
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.invalidate(key : String)
|
||||||
|
return unless enabled
|
||||||
|
@@cache.delete key
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.print
|
||||||
|
return unless @@should_log
|
||||||
|
sum = @@cache.sum { |_, entry| entry.instance_size }
|
||||||
|
Logger.debug "---- LRU Cache ----"
|
||||||
|
Logger.debug "Size: #{sum} Bytes"
|
||||||
|
Logger.debug "List:"
|
||||||
|
@@cache.each do |k, v|
|
||||||
|
Logger.debug "#{k} | #{v.atime} | #{v.instance_size}"
|
||||||
|
end
|
||||||
|
Logger.debug "-------------------"
|
||||||
|
end
|
||||||
|
|
||||||
|
private def self.is_cache_full
|
||||||
|
sum = @@cache.sum { |_, entry| entry.instance_size }
|
||||||
|
sum > @@limit
|
||||||
|
end
|
||||||
|
|
||||||
|
private def self.remove_least_recent_access
|
||||||
|
if @@should_log && is_cache_full
|
||||||
|
Logger.debug "Removing entries from LRUCache"
|
||||||
|
end
|
||||||
|
while is_cache_full && @@cache.size > 0
|
||||||
|
min_tuple = @@cache.min_by { |_, entry| entry.atime }
|
||||||
|
min_key = min_tuple[0]
|
||||||
|
min_entry = min_tuple[1]
|
||||||
|
|
||||||
|
Logger.debug " \
|
||||||
|
Target: #{min_key}, \
|
||||||
|
Last Access Time: #{min_entry.atime}" if @@should_log
|
||||||
|
invalidate min_key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+29
-17
@@ -1,6 +1,9 @@
|
|||||||
require "image_size"
|
require "image_size"
|
||||||
|
require "yaml"
|
||||||
|
|
||||||
class Entry
|
class Entry
|
||||||
|
include YAML::Serializable
|
||||||
|
|
||||||
getter zip_path : String, book : Title, title : String,
|
getter zip_path : String, book : Title, title : String,
|
||||||
size : String, pages : Int32, id : String, encoded_path : String,
|
size : String, pages : Int32, id : String, encoded_path : String,
|
||||||
encoded_title : String, mtime : Time, err_msg : String?
|
encoded_title : String, mtime : Time, err_msg : String?
|
||||||
@@ -46,32 +49,23 @@ class Entry
|
|||||||
file.close
|
file.close
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_slim_json : String
|
def build_json(*, slim = false)
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
{% for str in ["zip_path", "title", "size", "id"] %}
|
{% for str in ["zip_path", "title", "size", "id"] %}
|
||||||
json.field {{str}}, @{{str.id}}
|
json.field {{str}}, @{{str.id}}
|
||||||
{% end %}
|
{% end %}
|
||||||
json.field "title_id", @book.id
|
json.field "title_id", @book.id
|
||||||
json.field "title_title", @book.title
|
|
||||||
json.field "pages" { json.number @pages }
|
json.field "pages" { json.number @pages }
|
||||||
|
unless slim
|
||||||
|
json.field "display_name", @book.display_name @title
|
||||||
|
json.field "cover_url", cover_url
|
||||||
|
json.field "mtime" { json.number @mtime.to_unix }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
|
||||||
json.object do
|
|
||||||
{% for str in ["zip_path", "title", "size", "id"] %}
|
|
||||||
json.field {{str}}, @{{str.id}}
|
|
||||||
{% end %}
|
|
||||||
json.field "title_id", @book.id
|
|
||||||
json.field "display_name", @book.display_name @title
|
|
||||||
json.field "cover_url", cover_url
|
|
||||||
json.field "pages" { json.number @pages }
|
|
||||||
json.field "mtime" { json.number @mtime.to_unix }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def display_name
|
def display_name
|
||||||
@book.display_name @title
|
@book.display_name @title
|
||||||
end
|
end
|
||||||
@@ -82,9 +76,17 @@ class Entry
|
|||||||
|
|
||||||
def cover_url
|
def cover_url
|
||||||
return "#{Config.current.base_url}img/icon.png" if @err_msg
|
return "#{Config.current.base_url}img/icon.png" if @err_msg
|
||||||
|
|
||||||
|
unless @book.entry_cover_url_cache
|
||||||
|
TitleInfo.new @book.dir do |info|
|
||||||
|
@book.entry_cover_url_cache = info.entry_cover_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
entry_cover_url = @book.entry_cover_url_cache
|
||||||
|
|
||||||
url = "#{Config.current.base_url}api/cover/#{@book.id}/#{@id}"
|
url = "#{Config.current.base_url}api/cover/#{@book.id}/#{@id}"
|
||||||
TitleInfo.new @book.dir do |info|
|
if entry_cover_url
|
||||||
info_url = info.entry_cover_url[@title]?
|
info_url = entry_cover_url[@title]?
|
||||||
unless info_url.nil? || info_url.empty?
|
unless info_url.nil? || info_url.empty?
|
||||||
url = File.join Config.current.base_url, info_url
|
url = File.join Config.current.base_url, info_url
|
||||||
end
|
end
|
||||||
@@ -171,6 +173,16 @@ class Entry
|
|||||||
# For backward backward compatibility with v0.1.0, we save entry titles
|
# For backward backward compatibility with v0.1.0, we save entry titles
|
||||||
# instead of IDs in info.json
|
# instead of IDs in info.json
|
||||||
def save_progress(username, page)
|
def save_progress(username, page)
|
||||||
|
LRUCache.invalidate "#{@book.id}:#{username}:progress_sum"
|
||||||
|
@book.parents.each do |parent|
|
||||||
|
LRUCache.invalidate "#{parent.id}:#{username}:progress_sum"
|
||||||
|
end
|
||||||
|
[false, true].each do |ascend|
|
||||||
|
sorted_entries_cache_key = SortedEntriesCacheEntry.gen_key @book.id,
|
||||||
|
username, @book.entries, SortOptions.new(SortMethod::Progress, ascend)
|
||||||
|
LRUCache.invalidate sorted_entries_cache_key
|
||||||
|
end
|
||||||
|
|
||||||
TitleInfo.new @book.dir do |info|
|
TitleInfo.new @book.dir do |info|
|
||||||
if info.progress[username]?.nil?
|
if info.progress[username]?.nil?
|
||||||
info.progress[username] = {@title => page}
|
info.progress[username] = {@title => page}
|
||||||
|
|||||||
+75
-23
@@ -1,12 +1,38 @@
|
|||||||
class Library
|
class Library
|
||||||
|
include YAML::Serializable
|
||||||
|
|
||||||
getter dir : String, title_ids : Array(String),
|
getter dir : String, title_ids : Array(String),
|
||||||
title_hash : Hash(String, Title)
|
title_hash : Hash(String, Title)
|
||||||
|
|
||||||
use_default
|
use_default
|
||||||
|
|
||||||
def initialize
|
def save_instance
|
||||||
register_mime_types
|
path = Config.current.library_cache_path
|
||||||
|
Logger.debug "Caching library to #{path}"
|
||||||
|
|
||||||
|
writer = Compress::Gzip::Writer.new path,
|
||||||
|
Compress::Gzip::BEST_COMPRESSION
|
||||||
|
writer.write self.to_yaml.to_slice
|
||||||
|
writer.close
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.load_instance
|
||||||
|
path = Config.current.library_cache_path
|
||||||
|
return unless File.exists? path
|
||||||
|
|
||||||
|
Logger.debug "Loading cached library from #{path}"
|
||||||
|
|
||||||
|
begin
|
||||||
|
Compress::Gzip::Reader.open path do |content|
|
||||||
|
@@default = Library.from_yaml content
|
||||||
|
end
|
||||||
|
Library.default.register_jobs
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize
|
||||||
@dir = Config.current.library_path
|
@dir = Config.current.library_path
|
||||||
# explicitly initialize @titles to bypass the compiler check. it will
|
# explicitly initialize @titles to bypass the compiler check. it will
|
||||||
# be filled with actual Titles in the `scan` call below
|
# be filled with actual Titles in the `scan` call below
|
||||||
@@ -16,6 +42,12 @@ class Library
|
|||||||
@entries_count = 0
|
@entries_count = 0
|
||||||
@thumbnails_count = 0
|
@thumbnails_count = 0
|
||||||
|
|
||||||
|
register_jobs
|
||||||
|
end
|
||||||
|
|
||||||
|
protected def register_jobs
|
||||||
|
register_mime_types
|
||||||
|
|
||||||
scan_interval = Config.current.scan_interval_minutes
|
scan_interval = Config.current.scan_interval_minutes
|
||||||
if scan_interval < 1
|
if scan_interval < 1
|
||||||
scan
|
scan
|
||||||
@@ -25,7 +57,7 @@ class Library
|
|||||||
start = Time.local
|
start = Time.local
|
||||||
scan
|
scan
|
||||||
ms = (Time.local - start).total_milliseconds
|
ms = (Time.local - start).total_milliseconds
|
||||||
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
|
Logger.debug "Library initialized in #{ms}ms"
|
||||||
sleep scan_interval.minutes
|
sleep scan_interval.minutes
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -51,11 +83,6 @@ class Library
|
|||||||
def sorted_titles(username, opt : SortOptions? = nil)
|
def sorted_titles(username, opt : SortOptions? = nil)
|
||||||
if opt.nil?
|
if opt.nil?
|
||||||
opt = SortOptions.from_info_json @dir, username
|
opt = SortOptions.from_info_json @dir, username
|
||||||
else
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info.sort_by[username] = opt.to_tuple
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper function from src/util/util.cr
|
# Helper function from src/util/util.cr
|
||||||
@@ -66,14 +93,18 @@ class Library
|
|||||||
titles + titles.flat_map &.deep_titles
|
titles + titles.flat_map &.deep_titles
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_slim_json : String
|
def deep_entries
|
||||||
|
titles.flat_map &.deep_entries
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_json(*, slim = false, depth = -1)
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
json.field "dir", @dir
|
json.field "dir", @dir
|
||||||
json.field "titles" do
|
json.field "titles" do
|
||||||
json.array do
|
json.array do
|
||||||
self.titles.each do |title|
|
self.titles.each do |title|
|
||||||
json.raw title.to_slim_json
|
json.raw title.build_json(slim: slim, depth: depth)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -81,15 +112,6 @@ class Library
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
|
||||||
json.object do
|
|
||||||
json.field "dir", @dir
|
|
||||||
json.field "titles" do
|
|
||||||
json.raw self.titles.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_title(tid)
|
def get_title(tid)
|
||||||
@title_hash[tid]?
|
@title_hash[tid]?
|
||||||
end
|
end
|
||||||
@@ -99,6 +121,7 @@ class Library
|
|||||||
end
|
end
|
||||||
|
|
||||||
def scan
|
def scan
|
||||||
|
start = Time.local
|
||||||
unless Dir.exists? @dir
|
unless Dir.exists? @dir
|
||||||
Logger.info "The library directory #{@dir} does not exist. " \
|
Logger.info "The library directory #{@dir} does not exist. " \
|
||||||
"Attempting to create it"
|
"Attempting to create it"
|
||||||
@@ -107,14 +130,36 @@ class Library
|
|||||||
|
|
||||||
storage = Storage.new auto_close: false
|
storage = Storage.new auto_close: false
|
||||||
|
|
||||||
|
examine_context : ExamineContext = {
|
||||||
|
cached_contents_signature: {} of String => String,
|
||||||
|
deleted_title_ids: [] of String,
|
||||||
|
deleted_entry_ids: [] of String,
|
||||||
|
}
|
||||||
|
|
||||||
|
@title_ids.select! do |title_id|
|
||||||
|
title = @title_hash[title_id]
|
||||||
|
existence = title.examine examine_context
|
||||||
|
unless existence
|
||||||
|
examine_context["deleted_title_ids"].concat [title_id] +
|
||||||
|
title.deep_titles.map &.id
|
||||||
|
examine_context["deleted_entry_ids"].concat title.deep_entries.map &.id
|
||||||
|
end
|
||||||
|
existence
|
||||||
|
end
|
||||||
|
remained_title_dirs = @title_ids.map { |id| title_hash[id].dir }
|
||||||
|
examine_context["deleted_title_ids"].each do |title_id|
|
||||||
|
@title_hash.delete title_id
|
||||||
|
end
|
||||||
|
|
||||||
|
cache = examine_context["cached_contents_signature"]
|
||||||
(Dir.entries @dir)
|
(Dir.entries @dir)
|
||||||
.select { |fn| !fn.starts_with? "." }
|
.select { |fn| !fn.starts_with? "." }
|
||||||
.map { |fn| File.join @dir, fn }
|
.map { |fn| File.join @dir, fn }
|
||||||
|
.select { |path| !(remained_title_dirs.includes? path) }
|
||||||
.select { |path| File.directory? path }
|
.select { |path| File.directory? path }
|
||||||
.map { |path| Title.new path, "" }
|
.map { |path| Title.new path, "", cache }
|
||||||
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
||||||
.sort! { |a, b| a.title <=> b.title }
|
.sort! { |a, b| a.title <=> b.title }
|
||||||
.tap { |_| @title_ids.clear }
|
|
||||||
.each do |title|
|
.each do |title|
|
||||||
@title_hash[title.id] = title
|
@title_hash[title.id] = title
|
||||||
@title_ids << title.id
|
@title_ids << title.id
|
||||||
@@ -123,8 +168,15 @@ class Library
|
|||||||
storage.bulk_insert_ids
|
storage.bulk_insert_ids
|
||||||
storage.close
|
storage.close
|
||||||
|
|
||||||
Logger.debug "Scan completed"
|
ms = (Time.local - start).total_milliseconds
|
||||||
Storage.default.mark_unavailable
|
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
|
||||||
|
|
||||||
|
Storage.default.mark_unavailable examine_context["deleted_entry_ids"],
|
||||||
|
examine_context["deleted_title_ids"]
|
||||||
|
|
||||||
|
spawn do
|
||||||
|
save_instance
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_continue_reading_entries(username)
|
def get_continue_reading_entries(username)
|
||||||
|
|||||||
+180
-48
@@ -1,13 +1,25 @@
|
|||||||
|
require "digest"
|
||||||
require "../archive"
|
require "../archive"
|
||||||
|
|
||||||
class Title
|
class Title
|
||||||
|
include YAML::Serializable
|
||||||
|
|
||||||
getter dir : String, parent_id : String, title_ids : Array(String),
|
getter dir : String, parent_id : String, title_ids : Array(String),
|
||||||
entries : Array(Entry), title : String, id : String,
|
entries : Array(Entry), title : String, id : String,
|
||||||
encoded_title : String, mtime : Time, signature : UInt64
|
encoded_title : String, mtime : Time, signature : UInt64,
|
||||||
|
entry_cover_url_cache : Hash(String, String)?
|
||||||
|
setter entry_cover_url_cache : Hash(String, String)?
|
||||||
|
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
@entry_display_name_cache : Hash(String, String)?
|
@entry_display_name_cache : Hash(String, String)?
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
@entry_cover_url_cache : Hash(String, String)?
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
@cached_display_name : String?
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
@cached_cover_url : String?
|
||||||
|
|
||||||
def initialize(@dir : String, @parent_id)
|
def initialize(@dir : String, @parent_id, cache = {} of String => String)
|
||||||
storage = Storage.default
|
storage = Storage.default
|
||||||
@signature = Dir.signature dir
|
@signature = Dir.signature dir
|
||||||
id = storage.get_title_id dir, signature
|
id = storage.get_title_id dir, signature
|
||||||
@@ -20,6 +32,7 @@ class Title
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
@id = id
|
@id = id
|
||||||
|
@contents_signature = Dir.contents_signature dir, cache
|
||||||
@title = File.basename dir
|
@title = File.basename dir
|
||||||
@encoded_title = URI.encode @title
|
@encoded_title = URI.encode @title
|
||||||
@title_ids = [] of String
|
@title_ids = [] of String
|
||||||
@@ -30,7 +43,7 @@ class Title
|
|||||||
next if fn.starts_with? "."
|
next if fn.starts_with? "."
|
||||||
path = File.join dir, fn
|
path = File.join dir, fn
|
||||||
if File.directory? path
|
if File.directory? path
|
||||||
title = Title.new path, @id
|
title = Title.new path, @id, cache
|
||||||
next if title.entries.size == 0 && title.titles.size == 0
|
next if title.entries.size == 0 && title.titles.size == 0
|
||||||
Library.default.title_hash[title.id] = title
|
Library.default.title_hash[title.id] = title
|
||||||
@title_ids << title.id
|
@title_ids << title.id
|
||||||
@@ -57,24 +70,141 @@ class Title
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_slim_json : String
|
# Utility method used in library rescanning.
|
||||||
|
# - When the title does not exist on the file system anymore, return false
|
||||||
|
# and let it be deleted from the library instance
|
||||||
|
# - When the title exists, but its contents signature is now different from
|
||||||
|
# the cache, it means some of its content (nested titles or entries)
|
||||||
|
# has been added, deleted, or renamed. In this case we update its
|
||||||
|
# contents signature and instance variables
|
||||||
|
# - When the title exists and its contents signature is still the same, we
|
||||||
|
# return true so it can be reused without rescanning
|
||||||
|
def examine(context : ExamineContext) : Bool
|
||||||
|
return false unless Dir.exists? @dir
|
||||||
|
contents_signature = Dir.contents_signature @dir,
|
||||||
|
context["cached_contents_signature"]
|
||||||
|
return true if @contents_signature == contents_signature
|
||||||
|
|
||||||
|
@contents_signature = contents_signature
|
||||||
|
@signature = Dir.signature @dir
|
||||||
|
storage = Storage.default
|
||||||
|
id = storage.get_title_id dir, signature
|
||||||
|
if id.nil?
|
||||||
|
id = random_str
|
||||||
|
storage.insert_title_id({
|
||||||
|
path: dir,
|
||||||
|
id: id,
|
||||||
|
signature: signature.to_s,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
@id = id
|
||||||
|
@mtime = File.info(@dir).modification_time
|
||||||
|
|
||||||
|
previous_titles_size = @title_ids.size
|
||||||
|
@title_ids.select! do |title_id|
|
||||||
|
title = Library.default.get_title! title_id
|
||||||
|
existence = title.examine context
|
||||||
|
unless existence
|
||||||
|
context["deleted_title_ids"].concat [title_id] +
|
||||||
|
title.deep_titles.map &.id
|
||||||
|
context["deleted_entry_ids"].concat title.deep_entries.map &.id
|
||||||
|
end
|
||||||
|
existence
|
||||||
|
end
|
||||||
|
remained_title_dirs = @title_ids.map do |title_id|
|
||||||
|
title = Library.default.get_title! title_id
|
||||||
|
title.dir
|
||||||
|
end
|
||||||
|
|
||||||
|
previous_entries_size = @entries.size
|
||||||
|
@entries.select! do |entry|
|
||||||
|
existence = File.exists? entry.zip_path
|
||||||
|
Fiber.yield
|
||||||
|
context["deleted_entry_ids"] << entry.id unless existence
|
||||||
|
existence
|
||||||
|
end
|
||||||
|
remained_entry_zip_paths = @entries.map &.zip_path
|
||||||
|
|
||||||
|
is_titles_added = false
|
||||||
|
is_entries_added = false
|
||||||
|
Dir.entries(dir).each do |fn|
|
||||||
|
next if fn.starts_with? "."
|
||||||
|
path = File.join dir, fn
|
||||||
|
if File.directory? path
|
||||||
|
next if remained_title_dirs.includes? path
|
||||||
|
title = Title.new path, @id, context["cached_contents_signature"]
|
||||||
|
next if title.entries.size == 0 && title.titles.size == 0
|
||||||
|
Library.default.title_hash[title.id] = title
|
||||||
|
@title_ids << title.id
|
||||||
|
is_titles_added = true
|
||||||
|
next
|
||||||
|
end
|
||||||
|
if is_supported_file path
|
||||||
|
next if remained_entry_zip_paths.includes? path
|
||||||
|
entry = Entry.new path, self
|
||||||
|
if entry.pages > 0 || entry.err_msg
|
||||||
|
@entries << entry
|
||||||
|
is_entries_added = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
mtimes = [@mtime]
|
||||||
|
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
|
||||||
|
mtimes += @entries.map &.mtime
|
||||||
|
@mtime = mtimes.max
|
||||||
|
|
||||||
|
if is_titles_added || previous_titles_size != @title_ids.size
|
||||||
|
@title_ids.sort! do |a, b|
|
||||||
|
compare_numerically Library.default.title_hash[a].title,
|
||||||
|
Library.default.title_hash[b].title
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if is_entries_added || previous_entries_size != @entries.size
|
||||||
|
sorter = ChapterSorter.new @entries.map &.title
|
||||||
|
@entries.sort! do |a, b|
|
||||||
|
sorter.compare a.title, b.title
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
alias SortContext = NamedTuple(username: String, opt: SortOptions)
|
||||||
|
|
||||||
|
def build_json(*, slim = false, depth = -1,
|
||||||
|
sort_context : SortContext? = nil)
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
{% for str in ["dir", "title", "id"] %}
|
{% for str in ["dir", "title", "id"] %}
|
||||||
json.field {{str}}, @{{str.id}}
|
json.field {{str}}, @{{str.id}}
|
||||||
{% end %}
|
{% end %}
|
||||||
json.field "signature" { json.number @signature }
|
json.field "signature" { json.number @signature }
|
||||||
json.field "titles" do
|
unless slim
|
||||||
json.array do
|
json.field "display_name", display_name
|
||||||
self.titles.each do |title|
|
json.field "cover_url", cover_url
|
||||||
json.raw title.to_slim_json
|
json.field "mtime" { json.number @mtime.to_unix }
|
||||||
|
end
|
||||||
|
unless depth == 0
|
||||||
|
json.field "titles" do
|
||||||
|
json.array do
|
||||||
|
self.titles.each do |title|
|
||||||
|
json.raw title.build_json(slim: slim,
|
||||||
|
depth: depth > 0 ? depth - 1 : depth)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
json.field "entries" do
|
||||||
json.field "entries" do
|
json.array do
|
||||||
json.array do
|
_entries = if sort_context
|
||||||
@entries.each do |entry|
|
sorted_entries sort_context[:username],
|
||||||
json.raw entry.to_slim_json
|
sort_context[:opt]
|
||||||
|
else
|
||||||
|
@entries
|
||||||
|
end
|
||||||
|
_entries.each do |entry|
|
||||||
|
json.raw entry.build_json(slim: slim)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -92,34 +222,6 @@ class Title
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
|
||||||
json.object do
|
|
||||||
{% for str in ["dir", "title", "id"] %}
|
|
||||||
json.field {{str}}, @{{str.id}}
|
|
||||||
{% end %}
|
|
||||||
json.field "signature" { json.number @signature }
|
|
||||||
json.field "display_name", display_name
|
|
||||||
json.field "cover_url", cover_url
|
|
||||||
json.field "mtime" { json.number @mtime.to_unix }
|
|
||||||
json.field "titles" do
|
|
||||||
json.raw self.titles.to_json
|
|
||||||
end
|
|
||||||
json.field "entries" do
|
|
||||||
json.raw @entries.to_json
|
|
||||||
end
|
|
||||||
json.field "parents" do
|
|
||||||
json.array do
|
|
||||||
self.parents.each do |title|
|
|
||||||
json.object do
|
|
||||||
json.field "title", title.title
|
|
||||||
json.field "id", title.id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def titles
|
def titles
|
||||||
@title_ids.map { |tid| Library.default.get_title! tid }
|
@title_ids.map { |tid| Library.default.get_title! tid }
|
||||||
end
|
end
|
||||||
@@ -177,11 +279,15 @@ class Title
|
|||||||
end
|
end
|
||||||
|
|
||||||
def display_name
|
def display_name
|
||||||
|
cached_display_name = @cached_display_name
|
||||||
|
return cached_display_name unless cached_display_name.nil?
|
||||||
|
|
||||||
dn = @title
|
dn = @title
|
||||||
TitleInfo.new @dir do |info|
|
TitleInfo.new @dir do |info|
|
||||||
info_dn = info.display_name
|
info_dn = info.display_name
|
||||||
dn = info_dn unless info_dn.empty?
|
dn = info_dn unless info_dn.empty?
|
||||||
end
|
end
|
||||||
|
@cached_display_name = dn
|
||||||
dn
|
dn
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -205,6 +311,7 @@ class Title
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_display_name(dn)
|
def set_display_name(dn)
|
||||||
|
@cached_display_name = dn
|
||||||
TitleInfo.new @dir do |info|
|
TitleInfo.new @dir do |info|
|
||||||
info.display_name = dn
|
info.display_name = dn
|
||||||
info.save
|
info.save
|
||||||
@@ -214,11 +321,15 @@ class Title
|
|||||||
def set_display_name(entry_name : String, dn)
|
def set_display_name(entry_name : String, dn)
|
||||||
TitleInfo.new @dir do |info|
|
TitleInfo.new @dir do |info|
|
||||||
info.entry_display_name[entry_name] = dn
|
info.entry_display_name[entry_name] = dn
|
||||||
|
@entry_display_name_cache = info.entry_display_name
|
||||||
info.save
|
info.save
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def cover_url
|
def cover_url
|
||||||
|
cached_cover_url = @cached_cover_url
|
||||||
|
return cached_cover_url unless cached_cover_url.nil?
|
||||||
|
|
||||||
url = "#{Config.current.base_url}img/icon.png"
|
url = "#{Config.current.base_url}img/icon.png"
|
||||||
readable_entries = @entries.select &.err_msg.nil?
|
readable_entries = @entries.select &.err_msg.nil?
|
||||||
if readable_entries.size > 0
|
if readable_entries.size > 0
|
||||||
@@ -230,10 +341,12 @@ class Title
|
|||||||
url = File.join Config.current.base_url, info_url
|
url = File.join Config.current.base_url, info_url
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@cached_cover_url = url
|
||||||
url
|
url
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_cover_url(url : String)
|
def set_cover_url(url : String)
|
||||||
|
@cached_cover_url = url
|
||||||
TitleInfo.new @dir do |info|
|
TitleInfo.new @dir do |info|
|
||||||
info.cover_url = url
|
info.cover_url = url
|
||||||
info.save
|
info.save
|
||||||
@@ -243,6 +356,7 @@ class Title
|
|||||||
def set_cover_url(entry_name : String, url : String)
|
def set_cover_url(entry_name : String, url : String)
|
||||||
TitleInfo.new @dir do |info|
|
TitleInfo.new @dir do |info|
|
||||||
info.entry_cover_url[entry_name] = url
|
info.entry_cover_url[entry_name] = url
|
||||||
|
@entry_cover_url_cache = info.entry_cover_url
|
||||||
info.save
|
info.save
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -262,8 +376,15 @@ class Title
|
|||||||
end
|
end
|
||||||
|
|
||||||
def deep_read_page_count(username) : Int32
|
def deep_read_page_count(username) : Int32
|
||||||
load_progress_for_all_entries(username).sum +
|
key = "#{@id}:#{username}:progress_sum"
|
||||||
titles.flat_map(&.deep_read_page_count username).sum
|
sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
|
||||||
|
cached_sum = LRUCache.get key
|
||||||
|
return cached_sum[1] if cached_sum.is_a? Tuple(String, Int32) &&
|
||||||
|
cached_sum[0] == sig
|
||||||
|
sum = load_progress_for_all_entries(username, nil, true).sum +
|
||||||
|
titles.flat_map(&.deep_read_page_count username).sum
|
||||||
|
LRUCache.set generate_cache_entry key, {sig, sum}
|
||||||
|
sum
|
||||||
end
|
end
|
||||||
|
|
||||||
def deep_total_page_count : Int32
|
def deep_total_page_count : Int32
|
||||||
@@ -317,13 +438,12 @@ class Title
|
|||||||
# use the default (auto, ascending)
|
# use the default (auto, ascending)
|
||||||
# When `opt` is not nil, it saves the options to info.json
|
# When `opt` is not nil, it saves the options to info.json
|
||||||
def sorted_entries(username, opt : SortOptions? = nil)
|
def sorted_entries(username, opt : SortOptions? = nil)
|
||||||
|
cache_key = SortedEntriesCacheEntry.gen_key @id, username, @entries, opt
|
||||||
|
cached_entries = LRUCache.get cache_key
|
||||||
|
return cached_entries if cached_entries.is_a? Array(Entry)
|
||||||
|
|
||||||
if opt.nil?
|
if opt.nil?
|
||||||
opt = SortOptions.from_info_json @dir, username
|
opt = SortOptions.from_info_json @dir, username
|
||||||
else
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info.sort_by[username] = opt.to_tuple
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
case opt.not_nil!.method
|
case opt.not_nil!.method
|
||||||
@@ -355,6 +475,7 @@ class Title
|
|||||||
|
|
||||||
ary.reverse! unless opt.not_nil!.ascend
|
ary.reverse! unless opt.not_nil!.ascend
|
||||||
|
|
||||||
|
LRUCache.set generate_cache_entry cache_key, ary
|
||||||
ary
|
ary
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -416,6 +537,17 @@ class Title
|
|||||||
end
|
end
|
||||||
|
|
||||||
def bulk_progress(action, ids : Array(String), username)
|
def bulk_progress(action, ids : Array(String), username)
|
||||||
|
LRUCache.invalidate "#{@id}:#{username}:progress_sum"
|
||||||
|
parents.each do |parent|
|
||||||
|
LRUCache.invalidate "#{parent.id}:#{username}:progress_sum"
|
||||||
|
end
|
||||||
|
[false, true].each do |ascend|
|
||||||
|
sorted_entries_cache_key =
|
||||||
|
SortedEntriesCacheEntry.gen_key @id, username, @entries,
|
||||||
|
SortOptions.new(SortMethod::Progress, ascend)
|
||||||
|
LRUCache.invalidate sorted_entries_cache_key
|
||||||
|
end
|
||||||
|
|
||||||
selected_entries = ids
|
selected_entries = ids
|
||||||
.map { |id|
|
.map { |id|
|
||||||
@entries.find &.id.==(id)
|
@entries.find &.id.==(id)
|
||||||
|
|||||||
+29
-1
@@ -1,4 +1,12 @@
|
|||||||
SUPPORTED_IMG_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
SUPPORTED_IMG_TYPES = %w(
|
||||||
|
image/jpeg
|
||||||
|
image/png
|
||||||
|
image/webp
|
||||||
|
image/apng
|
||||||
|
image/avif
|
||||||
|
image/gif
|
||||||
|
image/svg+xml
|
||||||
|
)
|
||||||
|
|
||||||
enum SortMethod
|
enum SortMethod
|
||||||
Auto
|
Auto
|
||||||
@@ -88,6 +96,18 @@ class TitleInfo
|
|||||||
@@mutex_hash = {} of String => Mutex
|
@@mutex_hash = {} of String => Mutex
|
||||||
|
|
||||||
def self.new(dir, &)
|
def self.new(dir, &)
|
||||||
|
key = "#{dir}:info.json"
|
||||||
|
info = LRUCache.get key
|
||||||
|
if info.is_a? String
|
||||||
|
begin
|
||||||
|
instance = TitleInfo.from_json info
|
||||||
|
instance.dir = dir
|
||||||
|
yield instance
|
||||||
|
return
|
||||||
|
rescue
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
if @@mutex_hash[dir]?
|
if @@mutex_hash[dir]?
|
||||||
mutex = @@mutex_hash[dir]
|
mutex = @@mutex_hash[dir]
|
||||||
else
|
else
|
||||||
@@ -101,6 +121,7 @@ class TitleInfo
|
|||||||
instance = TitleInfo.from_json File.read json_path
|
instance = TitleInfo.from_json File.read json_path
|
||||||
end
|
end
|
||||||
instance.dir = dir
|
instance.dir = dir
|
||||||
|
LRUCache.set generate_cache_entry key, instance.to_json
|
||||||
yield instance
|
yield instance
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -108,5 +129,12 @@ class TitleInfo
|
|||||||
def save
|
def save
|
||||||
json_path = File.join @dir, "info.json"
|
json_path = File.join @dir, "info.json"
|
||||||
File.write json_path, self.to_pretty_json
|
File.write json_path, self.to_pretty_json
|
||||||
|
key = "#{@dir}:info.json"
|
||||||
|
LRUCache.set generate_cache_entry key, self.to_json
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
alias ExamineContext = NamedTuple(
|
||||||
|
cached_contents_signature: Hash(String, String),
|
||||||
|
deleted_title_ids: Array(String),
|
||||||
|
deleted_entry_ids: Array(String))
|
||||||
|
|||||||
+5
-1
@@ -34,7 +34,11 @@ class Logger
|
|||||||
end
|
end
|
||||||
|
|
||||||
@backend.formatter = Log::Formatter.new &format_proc
|
@backend.formatter = Log::Formatter.new &format_proc
|
||||||
Log.setup @@severity, @backend
|
|
||||||
|
Log.setup do |c|
|
||||||
|
c.bind "*", @@severity, @backend
|
||||||
|
c.bind "db.*", :error, @backend
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.get_severity(level = "") : Log::Severity
|
def self.get_severity(level = "") : Log::Severity
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
require "mangadex"
|
|
||||||
require "compress/zip"
|
|
||||||
require "../rename"
|
|
||||||
require "./ext"
|
|
||||||
|
|
||||||
module MangaDex
|
|
||||||
class PageJob
|
|
||||||
property success = false
|
|
||||||
property url : String
|
|
||||||
property filename : String
|
|
||||||
property writer : Compress::Zip::Writer
|
|
||||||
property tries_remaning : Int32
|
|
||||||
|
|
||||||
def initialize(@url, @filename, @writer, @tries_remaning)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Downloader < Queue::Downloader
|
|
||||||
@wait_seconds : Int32 = Config.current.mangadex["download_wait_seconds"]
|
|
||||||
.to_i32
|
|
||||||
@retries : Int32 = Config.current.mangadex["download_retries"].to_i32
|
|
||||||
|
|
||||||
use_default
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
@client = Client.from_config
|
|
||||||
super
|
|
||||||
end
|
|
||||||
|
|
||||||
def pop : Queue::Job?
|
|
||||||
job = nil
|
|
||||||
MainFiber.run do
|
|
||||||
DB.open "sqlite3://#{@queue.path}" do |db|
|
|
||||||
begin
|
|
||||||
db.query_one "select * from queue where id not like '%-%' " \
|
|
||||||
"and (status = 0 or status = 1) " \
|
|
||||||
"order by time limit 1" do |res|
|
|
||||||
job = Queue::Job.from_query_result res
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
job
|
|
||||||
end
|
|
||||||
|
|
||||||
private def download(job : Queue::Job)
|
|
||||||
@downloading = true
|
|
||||||
@queue.set_status Queue::JobStatus::Downloading, job
|
|
||||||
begin
|
|
||||||
chapter = @client.chapter job.id
|
|
||||||
# We must put the `.pages` call in a rescue block to handle external
|
|
||||||
# chapters.
|
|
||||||
pages = chapter.pages
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
@queue.set_status Queue::JobStatus::Error, job
|
|
||||||
unless e.message.nil?
|
|
||||||
@queue.add_message e.message.not_nil!, job
|
|
||||||
end
|
|
||||||
@downloading = false
|
|
||||||
return
|
|
||||||
end
|
|
||||||
@queue.set_pages pages.size, job
|
|
||||||
lib_dir = @library_path
|
|
||||||
rename_rule = Rename::Rule.new \
|
|
||||||
Config.current.mangadex["manga_rename_rule"].to_s
|
|
||||||
manga_dir = File.join lib_dir, chapter.manga.rename rename_rule
|
|
||||||
unless File.exists? manga_dir
|
|
||||||
Dir.mkdir_p manga_dir
|
|
||||||
end
|
|
||||||
zip_path = File.join manga_dir, "#{job.title}.cbz.part"
|
|
||||||
|
|
||||||
# Find the number of digits needed to store the number of pages
|
|
||||||
len = Math.log10(pages.size).to_i + 1
|
|
||||||
|
|
||||||
writer = Compress::Zip::Writer.new zip_path
|
|
||||||
# Create a buffered channel. It works as an FIFO queue
|
|
||||||
channel = Channel(PageJob).new pages.size
|
|
||||||
spawn do
|
|
||||||
pages.each_with_index do |url, i|
|
|
||||||
fn = Path.new(URI.parse(url).path).basename
|
|
||||||
ext = File.extname fn
|
|
||||||
fn = "#{i.to_s.rjust len, '0'}#{ext}"
|
|
||||||
page_job = PageJob.new url, fn, writer, @retries
|
|
||||||
Logger.debug "Downloading #{url}"
|
|
||||||
loop do
|
|
||||||
sleep @wait_seconds.seconds
|
|
||||||
download_page page_job
|
|
||||||
break if page_job.success ||
|
|
||||||
page_job.tries_remaning <= 0
|
|
||||||
page_job.tries_remaning -= 1
|
|
||||||
Logger.warn "Failed to download page #{url}. " \
|
|
||||||
"Retrying... Remaining retries: " \
|
|
||||||
"#{page_job.tries_remaning}"
|
|
||||||
end
|
|
||||||
|
|
||||||
channel.send page_job
|
|
||||||
break unless @queue.exists? job
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
spawn do
|
|
||||||
page_jobs = [] of PageJob
|
|
||||||
pages.size.times do
|
|
||||||
page_job = channel.receive
|
|
||||||
|
|
||||||
break unless @queue.exists? job
|
|
||||||
|
|
||||||
Logger.debug "[#{page_job.success ? "success" : "failed"}] " \
|
|
||||||
"#{page_job.url}"
|
|
||||||
page_jobs << page_job
|
|
||||||
if page_job.success
|
|
||||||
@queue.add_success job
|
|
||||||
else
|
|
||||||
@queue.add_fail job
|
|
||||||
msg = "Failed to download page #{page_job.url}"
|
|
||||||
@queue.add_message msg, job
|
|
||||||
Logger.error msg
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
unless @queue.exists? job
|
|
||||||
Logger.debug "Download cancelled"
|
|
||||||
@downloading = false
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
fail_count = page_jobs.count { |j| !j.success }
|
|
||||||
Logger.debug "Download completed. " \
|
|
||||||
"#{fail_count}/#{page_jobs.size} failed"
|
|
||||||
writer.close
|
|
||||||
filename = File.join File.dirname(zip_path), File.basename(zip_path,
|
|
||||||
".part")
|
|
||||||
File.rename zip_path, filename
|
|
||||||
Logger.debug "cbz File created at #{filename}"
|
|
||||||
|
|
||||||
zip_exception = validate_archive filename
|
|
||||||
if !zip_exception.nil?
|
|
||||||
@queue.add_message "The downloaded archive is corrupted. " \
|
|
||||||
"Error: #{zip_exception}", job
|
|
||||||
@queue.set_status Queue::JobStatus::Error, job
|
|
||||||
elsif fail_count > 0
|
|
||||||
@queue.set_status Queue::JobStatus::MissingPages, job
|
|
||||||
else
|
|
||||||
@queue.set_status Queue::JobStatus::Completed, job
|
|
||||||
end
|
|
||||||
@downloading = false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private def download_page(job : PageJob)
|
|
||||||
Logger.debug "downloading #{job.url}"
|
|
||||||
headers = HTTP::Headers{
|
|
||||||
"User-agent" => "Mangadex.cr",
|
|
||||||
}
|
|
||||||
begin
|
|
||||||
HTTP::Client.get job.url, headers do |res|
|
|
||||||
unless res.success?
|
|
||||||
raise "Failed to download page #{job.url}. " \
|
|
||||||
"[#{res.status_code}] #{res.status_message}"
|
|
||||||
end
|
|
||||||
job.writer.add job.filename, res.body_io
|
|
||||||
end
|
|
||||||
job.success = true
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
job.success = false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
private macro properties_to_hash(names)
|
|
||||||
{
|
|
||||||
{% for name in names %}
|
|
||||||
"{{name.id}}" => {{name.id}}.to_s,
|
|
||||||
{% end %}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Monkey-patch the structures in the `mangadex` shard to suit our needs
|
|
||||||
module MangaDex
|
|
||||||
struct Client
|
|
||||||
@@group_cache = {} of String => Group
|
|
||||||
|
|
||||||
def self.from_config : Client
|
|
||||||
self.new base_url: Config.current.mangadex["base_url"].to_s,
|
|
||||||
api_url: Config.current.mangadex["api_url"].to_s
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
struct Manga
|
|
||||||
def rename(rule : Rename::Rule)
|
|
||||||
rule.render properties_to_hash %w(id title author artist)
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_info_json
|
|
||||||
hash = JSON.parse(to_json).as_h
|
|
||||||
_chapters = chapters.map do |c|
|
|
||||||
JSON.parse c.to_info_json
|
|
||||||
end
|
|
||||||
hash["chapters"] = JSON::Any.new _chapters
|
|
||||||
hash.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
struct Chapter
|
|
||||||
def rename(rule : Rename::Rule)
|
|
||||||
hash = properties_to_hash %w(id title volume chapter lang_code language)
|
|
||||||
hash["groups"] = groups.join(",", &.name)
|
|
||||||
rule.render hash
|
|
||||||
end
|
|
||||||
|
|
||||||
def full_title
|
|
||||||
rule = Rename::Rule.new \
|
|
||||||
Config.current.mangadex["chapter_rename_rule"].to_s
|
|
||||||
rename rule
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_info_json
|
|
||||||
hash = JSON.parse(to_json).as_h
|
|
||||||
hash["language"] = JSON::Any.new language
|
|
||||||
_groups = {} of String => JSON::Any
|
|
||||||
groups.each do |g|
|
|
||||||
_groups[g.name] = JSON::Any.new g.id
|
|
||||||
end
|
|
||||||
hash["groups"] = JSON::Any.new _groups
|
|
||||||
hash["full_title"] = JSON::Any.new full_title
|
|
||||||
hash.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
+3
-3
@@ -2,13 +2,12 @@ require "./config"
|
|||||||
require "./queue"
|
require "./queue"
|
||||||
require "./server"
|
require "./server"
|
||||||
require "./main_fiber"
|
require "./main_fiber"
|
||||||
require "./mangadex/*"
|
|
||||||
require "./plugin/*"
|
require "./plugin/*"
|
||||||
require "option_parser"
|
require "option_parser"
|
||||||
require "clim"
|
require "clim"
|
||||||
require "tallboy"
|
require "tallboy"
|
||||||
|
|
||||||
MANGO_VERSION = "0.22.0"
|
MANGO_VERSION = "0.24.0"
|
||||||
|
|
||||||
# From http://www.network-science.de/ascii/
|
# From http://www.network-science.de/ascii/
|
||||||
BANNER = %{
|
BANNER = %{
|
||||||
@@ -56,10 +55,11 @@ class CLI < Clim
|
|||||||
Config.load(opts.config).set_current
|
Config.load(opts.config).set_current
|
||||||
|
|
||||||
# Initialize main components
|
# Initialize main components
|
||||||
|
LRUCache.init
|
||||||
Storage.default
|
Storage.default
|
||||||
Queue.default
|
Queue.default
|
||||||
|
Library.load_instance
|
||||||
Library.default
|
Library.default
|
||||||
MangaDex::Downloader.default
|
|
||||||
Plugin::Downloader.default
|
Plugin::Downloader.default
|
||||||
|
|
||||||
spawn do
|
spawn do
|
||||||
|
|||||||
@@ -23,11 +23,6 @@ class Plugin
|
|||||||
job
|
job
|
||||||
end
|
end
|
||||||
|
|
||||||
private def process_filename(str)
|
|
||||||
return "_" if str == ".."
|
|
||||||
str.gsub "/", "_"
|
|
||||||
end
|
|
||||||
|
|
||||||
private def download(job : Queue::Job)
|
private def download(job : Queue::Job)
|
||||||
@downloading = true
|
@downloading = true
|
||||||
@queue.set_status Queue::JobStatus::Downloading, job
|
@queue.set_status Queue::JobStatus::Downloading, job
|
||||||
@@ -42,8 +37,8 @@ class Plugin
|
|||||||
|
|
||||||
pages = info["pages"].as_i
|
pages = info["pages"].as_i
|
||||||
|
|
||||||
manga_title = process_filename job.manga_title
|
manga_title = sanitize_filename job.manga_title
|
||||||
chapter_title = process_filename info["title"].as_s
|
chapter_title = sanitize_filename info["title"].as_s
|
||||||
|
|
||||||
@queue.set_pages pages, job
|
@queue.set_pages pages, job
|
||||||
lib_dir = @library_path
|
lib_dir = @library_path
|
||||||
@@ -68,7 +63,7 @@ class Plugin
|
|||||||
while page = plugin.next_page
|
while page = plugin.next_page
|
||||||
break unless @queue.exists? job
|
break unless @queue.exists? job
|
||||||
|
|
||||||
fn = process_filename page["filename"].as_s
|
fn = sanitize_filename page["filename"].as_s
|
||||||
url = page["url"].as_s
|
url = page["url"].as_s
|
||||||
headers = HTTP::Headers.new
|
headers = HTTP::Headers.new
|
||||||
|
|
||||||
|
|||||||
+17
-139
@@ -2,8 +2,6 @@ require "duktape/runtime"
|
|||||||
require "myhtml"
|
require "myhtml"
|
||||||
require "xml"
|
require "xml"
|
||||||
|
|
||||||
require "./subscriptions"
|
|
||||||
|
|
||||||
class Plugin
|
class Plugin
|
||||||
class Error < ::Exception
|
class Error < ::Exception
|
||||||
end
|
end
|
||||||
@@ -18,19 +16,12 @@ class Plugin
|
|||||||
end
|
end
|
||||||
|
|
||||||
struct Info
|
struct Info
|
||||||
include JSON::Serializable
|
|
||||||
|
|
||||||
{% for name in ["id", "title", "placeholder"] %}
|
{% for name in ["id", "title", "placeholder"] %}
|
||||||
getter {{name.id}} = ""
|
getter {{name.id}} = ""
|
||||||
{% end %}
|
{% end %}
|
||||||
getter wait_seconds = 0u64
|
getter wait_seconds : UInt64 = 0
|
||||||
getter version = 0u64
|
|
||||||
getter settings = {} of String => String?
|
|
||||||
getter dir : String
|
getter dir : String
|
||||||
|
|
||||||
@[JSON::Field(ignore: true)]
|
|
||||||
@json : JSON::Any
|
|
||||||
|
|
||||||
def initialize(@dir)
|
def initialize(@dir)
|
||||||
info_path = File.join @dir, "info.json"
|
info_path = File.join @dir, "info.json"
|
||||||
|
|
||||||
@@ -46,16 +37,6 @@ class Plugin
|
|||||||
@{{name.id}} = @json[{{name}}].as_s
|
@{{name.id}} = @json[{{name}}].as_s
|
||||||
{% end %}
|
{% end %}
|
||||||
@wait_seconds = @json["wait_seconds"].as_i.to_u64
|
@wait_seconds = @json["wait_seconds"].as_i.to_u64
|
||||||
@version = @json["api_version"]?.try(&.as_i.to_u64) || 1u64
|
|
||||||
|
|
||||||
if @version > 1 && (settings_hash = @json["settings"]?.try &.as_h?)
|
|
||||||
settings_hash.each do |k, v|
|
|
||||||
unless str_value = v.as_s?
|
|
||||||
raise "The settings object can only contain strings or null"
|
|
||||||
end
|
|
||||||
@settings[k] = str_value
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
unless @id.alphanumeric_underscore?
|
unless @id.alphanumeric_underscore?
|
||||||
raise "Plugin ID can only contain alphanumeric characters and " \
|
raise "Plugin ID can only contain alphanumeric characters and " \
|
||||||
@@ -133,22 +114,6 @@ class Plugin
|
|||||||
@info.not_nil!
|
@info.not_nil!
|
||||||
end
|
end
|
||||||
|
|
||||||
def subscribe(subscription : Subscription)
|
|
||||||
list = SubscriptionList.new info.dir
|
|
||||||
list << subscription
|
|
||||||
list.save
|
|
||||||
end
|
|
||||||
|
|
||||||
def list_subscriptions
|
|
||||||
SubscriptionList.new(info.dir).ary
|
|
||||||
end
|
|
||||||
|
|
||||||
def unsubscribe(id : String)
|
|
||||||
list = SubscriptionList.new info.dir
|
|
||||||
list.reject &.id.== id
|
|
||||||
list.save
|
|
||||||
end
|
|
||||||
|
|
||||||
def initialize(id : String)
|
def initialize(id : String)
|
||||||
Plugin.build_info_ary
|
Plugin.build_info_ary
|
||||||
|
|
||||||
@@ -173,12 +138,6 @@ class Plugin
|
|||||||
sbx.push_string path
|
sbx.push_string path
|
||||||
sbx.put_prop_string -2, "storage_path"
|
sbx.put_prop_string -2, "storage_path"
|
||||||
|
|
||||||
sbx.push_pointer info.dir.as(Void*)
|
|
||||||
path = sbx.require_pointer(-1).as String
|
|
||||||
sbx.pop
|
|
||||||
sbx.push_string path
|
|
||||||
sbx.put_prop_string -2, "info_dir"
|
|
||||||
|
|
||||||
def_helper_functions sbx
|
def_helper_functions sbx
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -193,67 +152,23 @@ class Plugin
|
|||||||
{% end %}
|
{% end %}
|
||||||
end
|
end
|
||||||
|
|
||||||
def assert_manga_type(obj : JSON::Any)
|
|
||||||
obj["id"].as_s && obj["title"].as_s
|
|
||||||
rescue e
|
|
||||||
raise Error.new "Missing required fields in the Manga type"
|
|
||||||
end
|
|
||||||
|
|
||||||
def assert_chapter_type(obj : JSON::Any)
|
|
||||||
obj["id"].as_s && obj["title"].as_s && obj["pages"].as_i &&
|
|
||||||
obj["manga_title"].as_s
|
|
||||||
rescue e
|
|
||||||
raise Error.new "Missing required fields in the Chapter type"
|
|
||||||
end
|
|
||||||
|
|
||||||
def assert_page_type(obj : JSON::Any)
|
|
||||||
obj["url"].as_s && obj["filename"].as_s
|
|
||||||
rescue e
|
|
||||||
raise Error.new "Missing required fields in the Page type"
|
|
||||||
end
|
|
||||||
|
|
||||||
def search_manga(query : String)
|
|
||||||
if info.version == 1
|
|
||||||
raise Error.new "Manga searching is only available for plugins targeting API " \
|
|
||||||
"v2 or above"
|
|
||||||
end
|
|
||||||
json = eval_json "searchManga('#{query}')"
|
|
||||||
begin
|
|
||||||
json.as_a.each do |obj|
|
|
||||||
assert_manga_type obj
|
|
||||||
end
|
|
||||||
rescue e
|
|
||||||
raise Error.new e.message
|
|
||||||
end
|
|
||||||
json
|
|
||||||
end
|
|
||||||
|
|
||||||
def list_chapters(query : String)
|
def list_chapters(query : String)
|
||||||
json = eval_json "listChapters('#{query}')"
|
json = eval_json "listChapters('#{query}')"
|
||||||
begin
|
begin
|
||||||
if info.version > 1
|
check_fields ["title", "chapters"]
|
||||||
# Since v2, listChapters returns an array
|
|
||||||
json.as_a.each do |obj|
|
ary = json["chapters"].as_a
|
||||||
assert_chapter_type obj
|
ary.each do |obj|
|
||||||
|
id = obj["id"]?
|
||||||
|
raise "Field `id` missing from `listChapters` outputs" if id.nil?
|
||||||
|
|
||||||
|
unless id.to_s.alphanumeric_underscore?
|
||||||
|
raise "The `id` field can only contain alphanumeric characters " \
|
||||||
|
"and underscores"
|
||||||
end
|
end
|
||||||
else
|
|
||||||
check_fields ["title", "chapters"]
|
|
||||||
|
|
||||||
ary = json["chapters"].as_a
|
title = obj["title"]?
|
||||||
ary.each do |obj|
|
raise "Field `title` missing from `listChapters` outputs" if title.nil?
|
||||||
id = obj["id"]?
|
|
||||||
raise "Field `id` missing from `listChapters` outputs" if id.nil?
|
|
||||||
|
|
||||||
unless id.to_s.alphanumeric_underscore?
|
|
||||||
raise "The `id` field can only contain alphanumeric characters " \
|
|
||||||
"and underscores"
|
|
||||||
end
|
|
||||||
|
|
||||||
title = obj["title"]?
|
|
||||||
if title.nil?
|
|
||||||
raise "Field `title` missing from `listChapters` outputs"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
rescue e
|
rescue e
|
||||||
raise Error.new e.message
|
raise Error.new e.message
|
||||||
@@ -264,14 +179,10 @@ class Plugin
|
|||||||
def select_chapter(id : String)
|
def select_chapter(id : String)
|
||||||
json = eval_json "selectChapter('#{id}')"
|
json = eval_json "selectChapter('#{id}')"
|
||||||
begin
|
begin
|
||||||
if info.version > 1
|
check_fields ["title", "pages"]
|
||||||
assert_chapter_type json
|
|
||||||
else
|
|
||||||
check_fields ["title", "pages"]
|
|
||||||
|
|
||||||
if json["title"].to_s.empty?
|
if json["title"].to_s.empty?
|
||||||
raise "The `title` field of the chapter can not be empty"
|
raise "The `title` field of the chapter can not be empty"
|
||||||
end
|
|
||||||
end
|
end
|
||||||
rescue e
|
rescue e
|
||||||
raise Error.new e.message
|
raise Error.new e.message
|
||||||
@@ -283,19 +194,7 @@ class Plugin
|
|||||||
json = eval_json "nextPage()"
|
json = eval_json "nextPage()"
|
||||||
return if json.size == 0
|
return if json.size == 0
|
||||||
begin
|
begin
|
||||||
assert_page_type json
|
check_fields ["filename", "url"]
|
||||||
rescue e
|
|
||||||
raise Error.new e.message
|
|
||||||
end
|
|
||||||
json
|
|
||||||
end
|
|
||||||
|
|
||||||
def new_chapters(manga_id : String, after : Int64)
|
|
||||||
json = eval_json "newChapters('#{manga_id}', #{after})"
|
|
||||||
begin
|
|
||||||
json.as_a.each do |obj|
|
|
||||||
assert_chapter_type obj
|
|
||||||
end
|
|
||||||
rescue e
|
rescue e
|
||||||
raise Error.new e.message
|
raise Error.new e.message
|
||||||
end
|
end
|
||||||
@@ -480,27 +379,6 @@ class Plugin
|
|||||||
end
|
end
|
||||||
sbx.put_prop_string -2, "storage"
|
sbx.put_prop_string -2, "storage"
|
||||||
|
|
||||||
if info.version > 1
|
|
||||||
sbx.push_proc 1 do |ptr|
|
|
||||||
env = Duktape::Sandbox.new ptr
|
|
||||||
key = env.require_string 0
|
|
||||||
|
|
||||||
env.get_global_string "info_dir"
|
|
||||||
info_dir = env.require_string -1
|
|
||||||
env.pop
|
|
||||||
info = Info.new info_dir
|
|
||||||
|
|
||||||
if value = info.settings[key]?
|
|
||||||
env.push_string value
|
|
||||||
else
|
|
||||||
env.push_undefined
|
|
||||||
end
|
|
||||||
|
|
||||||
env.call_success
|
|
||||||
end
|
|
||||||
sbx.put_prop_string -2, "settings"
|
|
||||||
end
|
|
||||||
|
|
||||||
sbx.put_prop_string -2, "mango"
|
sbx.put_prop_string -2, "mango"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
require "uuid"
|
|
||||||
|
|
||||||
enum FilterType
|
|
||||||
String
|
|
||||||
NumMin
|
|
||||||
NumMax
|
|
||||||
DateMin
|
|
||||||
DateMax
|
|
||||||
Array
|
|
||||||
|
|
||||||
def self.from_string(str)
|
|
||||||
case str
|
|
||||||
when "string"
|
|
||||||
String
|
|
||||||
when "number-min"
|
|
||||||
NumMin
|
|
||||||
when "number-max"
|
|
||||||
NumMax
|
|
||||||
when "date-min"
|
|
||||||
DateMin
|
|
||||||
when "date-max"
|
|
||||||
DateMax
|
|
||||||
when "array"
|
|
||||||
Array
|
|
||||||
else
|
|
||||||
raise "Unknown filter type with string #{str}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
struct Filter
|
|
||||||
include JSON::Serializable
|
|
||||||
|
|
||||||
property key : String
|
|
||||||
property value : String | Int32 | Int64 | Float32 | Nil
|
|
||||||
property type : FilterType
|
|
||||||
|
|
||||||
def initialize(@key, @value, @type)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.from_json(str) : Filter
|
|
||||||
json = JSON.parse str
|
|
||||||
key = json["key"].as_s
|
|
||||||
type = FilterType.from_string json["type"].as_s
|
|
||||||
_value = json["value"]
|
|
||||||
value = _value.as_s? || _value.as_i32? || _value.as_i64? ||
|
|
||||||
_value.as_f32? || nil
|
|
||||||
self.new key, value, type
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
struct Subscription
|
|
||||||
include JSON::Serializable
|
|
||||||
|
|
||||||
property id : String
|
|
||||||
property plugin_id : String
|
|
||||||
property name : String
|
|
||||||
property created_at : Int64
|
|
||||||
property last_checked : Int64
|
|
||||||
property filters = [] of Filter
|
|
||||||
|
|
||||||
def initialize(@plugin_id, @name)
|
|
||||||
@id = UUID.random.to_s
|
|
||||||
@created_at = Time.utc.to_unix
|
|
||||||
@last_checked = Time.utc.to_unix
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
struct SubscriptionList
|
|
||||||
@dir : String
|
|
||||||
@path : String
|
|
||||||
|
|
||||||
getter ary = [] of Subscription
|
|
||||||
|
|
||||||
forward_missing_to @ary
|
|
||||||
|
|
||||||
def initialize(@dir)
|
|
||||||
@path = Path[@dir, "subscriptions.json"]
|
|
||||||
if File.exists? @path
|
|
||||||
@ary = Array(Subscription).from_json File.read @path
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def save
|
|
||||||
File.write @path, @ary.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -73,9 +73,5 @@ struct AdminRouter
|
|||||||
get "/admin/missing" do |env|
|
get "/admin/missing" do |env|
|
||||||
layout "missing-items"
|
layout "missing-items"
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/admin/mangadex" do |env|
|
|
||||||
layout "mangadex"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+95
-294
@@ -1,6 +1,6 @@
|
|||||||
require "../mangadex/*"
|
|
||||||
require "../upload"
|
require "../upload"
|
||||||
require "koa"
|
require "koa"
|
||||||
|
require "digest"
|
||||||
|
|
||||||
struct APIRouter
|
struct APIRouter
|
||||||
@@api_json : String?
|
@@api_json : String?
|
||||||
@@ -23,7 +23,7 @@ struct APIRouter
|
|||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
|
|
||||||
All endpoints require authentication. After logging in, your session ID would be stored as a cookie named `mango-sessid-#{Config.current.port}`, which can be used to authenticate the API access. Note that all admin API endpoints (`/api/admin/...`) require the logged-in user to have admin access.
|
All endpoints except `/api/login` require authentication. After logging in, your session ID would be stored as a cookie named `mango-sessid-#{Config.current.port}`, which can be used to authenticate the API access. Note that all admin API endpoints (`/api/admin/...`) require the logged-in user to have admin access.
|
||||||
|
|
||||||
# Terminologies
|
# Terminologies
|
||||||
|
|
||||||
@@ -56,18 +56,28 @@ struct APIRouter
|
|||||||
"error" => String?,
|
"error" => String?,
|
||||||
}
|
}
|
||||||
|
|
||||||
Koa.schema("mdChapter", {
|
Koa.describe "Authenticates a user", <<-MD
|
||||||
"id" => Int64,
|
After successful login, the cookie `mango-sessid-#{Config.current.port}` will contain a valid session ID that can be used for subsequent requests
|
||||||
"group" => {} of String => String,
|
MD
|
||||||
}.merge(s %w(title volume chapter language full_title time
|
Koa.body schema: {
|
||||||
manga_title manga_id)),
|
"username" => String,
|
||||||
desc: "A MangaDex chapter")
|
"password" => String,
|
||||||
|
}
|
||||||
|
Koa.tag "users"
|
||||||
|
post "/api/login" do |env|
|
||||||
|
begin
|
||||||
|
username = env.params.json["username"].as String
|
||||||
|
password = env.params.json["password"].as String
|
||||||
|
token = Storage.default.verify_user(username, password).not_nil!
|
||||||
|
|
||||||
Koa.schema "mdManga", {
|
env.session.string "token", token
|
||||||
"id" => Int64,
|
"Authenticated"
|
||||||
"chapters" => ["mdChapter"],
|
rescue e
|
||||||
}.merge(s %w(title description author artist cover_url)),
|
Logger.error e
|
||||||
desc: "A MangaDex manga"
|
env.response.status_code = 403
|
||||||
|
e.message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
Koa.describe "Returns a page in a manga entry"
|
Koa.describe "Returns a page in a manga entry"
|
||||||
Koa.path "tid", desc: "Title ID"
|
Koa.path "tid", desc: "Title ID"
|
||||||
@@ -75,12 +85,14 @@ struct APIRouter
|
|||||||
Koa.path "page", schema: Int32, desc: "The page number to return (starts from 1)"
|
Koa.path "page", schema: Int32, desc: "The page number to return (starts from 1)"
|
||||||
Koa.response 200, schema: Bytes, media_type: "image/*"
|
Koa.response 200, schema: Bytes, media_type: "image/*"
|
||||||
Koa.response 500, "Page not found or not readable"
|
Koa.response 500, "Page not found or not readable"
|
||||||
|
Koa.response 304, "Page not modified (only available when `If-None-Match` is set)"
|
||||||
Koa.tag "reader"
|
Koa.tag "reader"
|
||||||
get "/api/page/:tid/:eid/:page" do |env|
|
get "/api/page/:tid/:eid/:page" do |env|
|
||||||
begin
|
begin
|
||||||
tid = env.params.url["tid"]
|
tid = env.params.url["tid"]
|
||||||
eid = env.params.url["eid"]
|
eid = env.params.url["eid"]
|
||||||
page = env.params.url["page"].to_i
|
page = env.params.url["page"].to_i
|
||||||
|
prev_e_tag = env.request.headers["If-None-Match"]?
|
||||||
|
|
||||||
title = Library.default.get_title tid
|
title = Library.default.get_title tid
|
||||||
raise "Title ID `#{tid}` not found" if title.nil?
|
raise "Title ID `#{tid}` not found" if title.nil?
|
||||||
@@ -90,7 +102,15 @@ struct APIRouter
|
|||||||
raise "Failed to load page #{page} of " \
|
raise "Failed to load page #{page} of " \
|
||||||
"`#{title.title}/#{entry.title}`" if img.nil?
|
"`#{title.title}/#{entry.title}`" if img.nil?
|
||||||
|
|
||||||
send_img env, img
|
e_tag = Digest::SHA1.hexdigest img.data
|
||||||
|
if prev_e_tag == e_tag
|
||||||
|
env.response.status_code = 304
|
||||||
|
""
|
||||||
|
else
|
||||||
|
env.response.headers["ETag"] = e_tag
|
||||||
|
env.response.headers["Cache-Control"] = "public, max-age=86400"
|
||||||
|
send_img env, img
|
||||||
|
end
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
@@ -102,12 +122,14 @@ struct APIRouter
|
|||||||
Koa.path "tid", desc: "Title ID"
|
Koa.path "tid", desc: "Title ID"
|
||||||
Koa.path "eid", desc: "Entry ID"
|
Koa.path "eid", desc: "Entry ID"
|
||||||
Koa.response 200, schema: Bytes, media_type: "image/*"
|
Koa.response 200, schema: Bytes, media_type: "image/*"
|
||||||
|
Koa.response 304, "Page not modified (only available when `If-None-Match` is set)"
|
||||||
Koa.response 500, "Page not found or not readable"
|
Koa.response 500, "Page not found or not readable"
|
||||||
Koa.tag "library"
|
Koa.tag "library"
|
||||||
get "/api/cover/:tid/:eid" do |env|
|
get "/api/cover/:tid/:eid" do |env|
|
||||||
begin
|
begin
|
||||||
tid = env.params.url["tid"]
|
tid = env.params.url["tid"]
|
||||||
eid = env.params.url["eid"]
|
eid = env.params.url["eid"]
|
||||||
|
prev_e_tag = env.request.headers["If-None-Match"]?
|
||||||
|
|
||||||
title = Library.default.get_title tid
|
title = Library.default.get_title tid
|
||||||
raise "Title ID `#{tid}` not found" if title.nil?
|
raise "Title ID `#{tid}` not found" if title.nil?
|
||||||
@@ -118,7 +140,14 @@ struct APIRouter
|
|||||||
raise "Failed to get cover of `#{title.title}/#{entry.title}`" \
|
raise "Failed to get cover of `#{title.title}/#{entry.title}`" \
|
||||||
if img.nil?
|
if img.nil?
|
||||||
|
|
||||||
send_img env, img
|
e_tag = Digest::SHA1.hexdigest img.data
|
||||||
|
if prev_e_tag == e_tag
|
||||||
|
env.response.status_code = 304
|
||||||
|
""
|
||||||
|
else
|
||||||
|
env.response.headers["ETag"] = e_tag
|
||||||
|
send_img env, img
|
||||||
|
end
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
@@ -127,24 +156,38 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Returns the book with title `tid`", <<-MD
|
Koa.describe "Returns the book with title `tid`", <<-MD
|
||||||
Supply the `tid` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
||||||
|
- Supply the `depth` query parameter to control the depth of nested titles to return.
|
||||||
|
- When `depth` is 1, returns the top-level titles and sub-titles/entries one level in them
|
||||||
|
- When `depth` is 0, returns the top-level titles without their sub-titles/entries
|
||||||
|
- When `depth` is N, returns the top-level titles and sub-titles/entries N levels in them
|
||||||
|
- When `depth` is negative, returns the entire library
|
||||||
MD
|
MD
|
||||||
Koa.path "tid", desc: "Title ID"
|
Koa.path "tid", desc: "Title ID"
|
||||||
Koa.query "slim"
|
Koa.query "slim"
|
||||||
|
Koa.query "depth"
|
||||||
|
Koa.query "sort", desc: "Sorting option for entries. Can be one of 'auto', 'title', 'progress', 'time_added' and 'time_modified'"
|
||||||
|
Koa.query "ascend", desc: "Sorting direction for entries. Set to 0 for the descending order. Doesn't work without specifying 'sort'"
|
||||||
Koa.response 200, schema: "title"
|
Koa.response 200, schema: "title"
|
||||||
Koa.response 404, "Title not found"
|
Koa.response 404, "Title not found"
|
||||||
Koa.tag "library"
|
Koa.tag "library"
|
||||||
get "/api/book/:tid" do |env|
|
get "/api/book/:tid" do |env|
|
||||||
begin
|
begin
|
||||||
|
username = get_username env
|
||||||
|
|
||||||
|
sort_opt = SortOptions.new
|
||||||
|
get_sort_opt
|
||||||
|
|
||||||
tid = env.params.url["tid"]
|
tid = env.params.url["tid"]
|
||||||
title = Library.default.get_title tid
|
title = Library.default.get_title tid
|
||||||
raise "Title ID `#{tid}` not found" if title.nil?
|
raise "Title ID `#{tid}` not found" if title.nil?
|
||||||
|
|
||||||
if env.params.query["slim"]?
|
slim = !env.params.query["slim"]?.nil?
|
||||||
send_json env, title.to_slim_json
|
depth = env.params.query["depth"]?.try(&.to_i?) || -1
|
||||||
else
|
|
||||||
send_json env, title.to_json
|
send_json env, title.build_json(slim: slim, depth: depth,
|
||||||
end
|
sort_context: {username: username,
|
||||||
|
opt: sort_opt})
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
@@ -153,20 +196,25 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Returns the entire library with all titles and entries", <<-MD
|
Koa.describe "Returns the entire library with all titles and entries", <<-MD
|
||||||
Supply the `tid` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
||||||
|
- Supply the `dpeth` query parameter to control the depth of nested titles to return.
|
||||||
|
- When `depth` is 1, returns the requested title and sub-titles/entries one level in it
|
||||||
|
- When `depth` is 0, returns the requested title without its sub-titles/entries
|
||||||
|
- When `depth` is N, returns the requested title and sub-titles/entries N levels in it
|
||||||
|
- When `depth` is negative, returns the requested title and all sub-titles/entries in it
|
||||||
MD
|
MD
|
||||||
Koa.query "slim"
|
Koa.query "slim"
|
||||||
|
Koa.query "depth"
|
||||||
Koa.response 200, schema: {
|
Koa.response 200, schema: {
|
||||||
"dir" => String,
|
"dir" => String,
|
||||||
"titles" => ["title"],
|
"titles" => ["title"],
|
||||||
}
|
}
|
||||||
Koa.tag "library"
|
Koa.tag "library"
|
||||||
get "/api/library" do |env|
|
get "/api/library" do |env|
|
||||||
if env.params.query["slim"]?
|
slim = !env.params.query["slim"]?.nil?
|
||||||
send_json env, Library.default.to_slim_json
|
depth = env.params.query["depth"]?.try(&.to_i?) || -1
|
||||||
else
|
|
||||||
send_json env, Library.default.to_json
|
send_json env, Library.default.build_json(slim: slim, depth: depth)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Triggers a library scan"
|
Koa.describe "Triggers a library scan"
|
||||||
@@ -323,58 +371,6 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Returns a MangaDex manga identified by `id`", <<-MD
|
|
||||||
On error, returns a JSON that contains the error message in the `error` field.
|
|
||||||
MD
|
|
||||||
Koa.tags ["admin", "mangadex"]
|
|
||||||
Koa.path "id", desc: "A MangaDex manga ID"
|
|
||||||
Koa.response 200, schema: "mdManga"
|
|
||||||
get "/api/admin/mangadex/manga/:id" do |env|
|
|
||||||
begin
|
|
||||||
id = env.params.url["id"]
|
|
||||||
manga = MangaDex::Client.from_config.manga id
|
|
||||||
send_json env, manga.to_info_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {"error" => e.message}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Adds a list of MangaDex chapters to the download queue", <<-MD
|
|
||||||
On error, returns a JSON that contains the error message in the `error` field.
|
|
||||||
MD
|
|
||||||
Koa.tags ["admin", "mangadex", "downloader"]
|
|
||||||
Koa.body schema: {
|
|
||||||
"chapters" => ["mdChapter"],
|
|
||||||
}
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Int32,
|
|
||||||
"fail" => Int32,
|
|
||||||
}
|
|
||||||
post "/api/admin/mangadex/download" do |env|
|
|
||||||
begin
|
|
||||||
chapters = env.params.json["chapters"].as(Array).map &.as_h
|
|
||||||
jobs = chapters.map { |chapter|
|
|
||||||
Queue::Job.new(
|
|
||||||
chapter["id"].as_i64.to_s,
|
|
||||||
chapter["mangaId"].as_i64.to_s,
|
|
||||||
chapter["full_title"].as_s,
|
|
||||||
chapter["mangaTitle"].as_s,
|
|
||||||
Queue::JobStatus::Pending,
|
|
||||||
Time.unix chapter["timestamp"].as_i64
|
|
||||||
)
|
|
||||||
}
|
|
||||||
inserted_count = Queue.default.push jobs
|
|
||||||
send_json env, {
|
|
||||||
"success": inserted_count,
|
|
||||||
"fail": jobs.size - inserted_count,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {"error" => e.message}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
ws "/api/admin/mangadex/queue" do |socket, env|
|
ws "/api/admin/mangadex/queue" do |socket, env|
|
||||||
interval_raw = env.params.query["interval"]?
|
interval_raw = env.params.query["interval"]?
|
||||||
interval = (interval_raw.to_i? if interval_raw) || 5
|
interval = (interval_raw.to_i? if interval_raw) || 5
|
||||||
@@ -539,97 +535,6 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Returns a list of available plugins"
|
|
||||||
Koa.tags ["admin", "downloader"]
|
|
||||||
Koa.query "plugin", schema: String
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"plugins" => [{
|
|
||||||
"id" => String,
|
|
||||||
"title" => String,
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
get "/api/admin/plugin" do |env|
|
|
||||||
begin
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"plugins" => Plugin.list,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Returns the metadata of a plugin"
|
|
||||||
Koa.tags ["admin", "downloader"]
|
|
||||||
Koa.query "plugin", schema: String
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"info" => {
|
|
||||||
"dir" => String,
|
|
||||||
"id" => String,
|
|
||||||
"title" => String,
|
|
||||||
"placeholder" => String,
|
|
||||||
"wait_seconds" => Int32,
|
|
||||||
"version" => Int32,
|
|
||||||
"settings" => {} of String => String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
get "/api/admin/plugin/info" do |env|
|
|
||||||
begin
|
|
||||||
plugin = Plugin.new env.params.query["plugin"].as String
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"info" => plugin.info,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Searches for manga matching the given query from a plugin", <<-MD
|
|
||||||
Only available for plugins targeting API v2 or above.
|
|
||||||
MD
|
|
||||||
Koa.tags ["admin", "downloader"]
|
|
||||||
Koa.query "plugin", schema: String
|
|
||||||
Koa.query "query", schema: String
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"manga" => [{
|
|
||||||
"id" => String,
|
|
||||||
"title" => String,
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
get "/api/admin/plugin/search" do |env|
|
|
||||||
begin
|
|
||||||
query = env.params.query["query"].as String
|
|
||||||
plugin = Plugin.new env.params.query["plugin"].as String
|
|
||||||
|
|
||||||
manga_ary = plugin.search_manga(query).as_a
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"manga" => manga_ary,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Lists the chapters in a title from a plugin"
|
Koa.describe "Lists the chapters in a title from a plugin"
|
||||||
Koa.tags ["admin", "downloader"]
|
Koa.tags ["admin", "downloader"]
|
||||||
Koa.query "plugin", schema: String
|
Koa.query "plugin", schema: String
|
||||||
@@ -638,8 +543,8 @@ struct APIRouter
|
|||||||
"success" => Bool,
|
"success" => Bool,
|
||||||
"error" => String?,
|
"error" => String?,
|
||||||
"chapters?" => [{
|
"chapters?" => [{
|
||||||
"id" => String,
|
"id" => String,
|
||||||
"title?" => String,
|
"title" => String,
|
||||||
}],
|
}],
|
||||||
"title" => String?,
|
"title" => String?,
|
||||||
}
|
}
|
||||||
@@ -649,14 +554,8 @@ struct APIRouter
|
|||||||
plugin = Plugin.new env.params.query["plugin"].as String
|
plugin = Plugin.new env.params.query["plugin"].as String
|
||||||
|
|
||||||
json = plugin.list_chapters query
|
json = plugin.list_chapters query
|
||||||
|
chapters = json["chapters"]
|
||||||
if plugin.info.version == 1
|
title = json["title"]
|
||||||
chapters = json["chapters"]
|
|
||||||
title = json["title"]
|
|
||||||
else
|
|
||||||
chapters = json
|
|
||||||
title = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => true,
|
"success" => true,
|
||||||
@@ -728,21 +627,32 @@ struct APIRouter
|
|||||||
"height" => Int32,
|
"height" => Int32,
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
|
Koa.response 304, "Not modified (only available when `If-None-Match` is set)"
|
||||||
get "/api/dimensions/:tid/:eid" do |env|
|
get "/api/dimensions/:tid/:eid" do |env|
|
||||||
begin
|
begin
|
||||||
tid = env.params.url["tid"]
|
tid = env.params.url["tid"]
|
||||||
eid = env.params.url["eid"]
|
eid = env.params.url["eid"]
|
||||||
|
prev_e_tag = env.request.headers["If-None-Match"]?
|
||||||
|
|
||||||
title = Library.default.get_title tid
|
title = Library.default.get_title tid
|
||||||
raise "Title ID `#{tid}` not found" if title.nil?
|
raise "Title ID `#{tid}` not found" if title.nil?
|
||||||
entry = title.get_entry eid
|
entry = title.get_entry eid
|
||||||
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
||||||
|
|
||||||
sizes = entry.page_dimensions
|
file_hash = Digest::SHA1.hexdigest (entry.zip_path + entry.mtime.to_s)
|
||||||
send_json env, {
|
e_tag = "W/#{file_hash}"
|
||||||
"success" => true,
|
if e_tag == prev_e_tag
|
||||||
"dimensions" => sizes,
|
env.response.status_code = 304
|
||||||
}.to_json
|
""
|
||||||
|
else
|
||||||
|
sizes = entry.page_dimensions
|
||||||
|
env.response.headers["ETag"] = e_tag
|
||||||
|
env.response.headers["Cache-Control"] = "public, max-age=86400"
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"dimensions" => sizes,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
@@ -1001,115 +911,6 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Logs the current user into their MangaDex account", <<-MD
|
|
||||||
If successful, returns the expiration date (as a unix timestamp) of the newly created token.
|
|
||||||
MD
|
|
||||||
Koa.body schema: {
|
|
||||||
"username" => String,
|
|
||||||
"password" => String,
|
|
||||||
}
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"expires" => Int64?,
|
|
||||||
}
|
|
||||||
Koa.tags ["admin", "mangadex", "users"]
|
|
||||||
post "/api/admin/mangadex/login" do |env|
|
|
||||||
begin
|
|
||||||
username = env.params.json["username"].as String
|
|
||||||
password = env.params.json["password"].as String
|
|
||||||
mango_username = get_username env
|
|
||||||
|
|
||||||
client = MangaDex::Client.from_config
|
|
||||||
client.auth username, password
|
|
||||||
|
|
||||||
Storage.default.save_md_token mango_username, client.token.not_nil!,
|
|
||||||
client.token_expires
|
|
||||||
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
"expires" => client.token_expires.to_unix,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Returns the expiration date (as a unix timestamp) of the mangadex token if it exists"
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"expires" => Int64?,
|
|
||||||
}
|
|
||||||
Koa.tags ["admin", "mangadex", "users"]
|
|
||||||
get "/api/admin/mangadex/expires" do |env|
|
|
||||||
begin
|
|
||||||
username = get_username env
|
|
||||||
_, expires = Storage.default.get_md_token username
|
|
||||||
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
"expires" => expires.try &.to_unix,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Searches MangaDex for manga matching `query`", <<-MD
|
|
||||||
Returns an empty list if the current user hasn't logged in to MangaDex.
|
|
||||||
MD
|
|
||||||
Koa.query "query"
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"manga?" => [{
|
|
||||||
"id" => Int64,
|
|
||||||
"title" => String,
|
|
||||||
"description" => String,
|
|
||||||
"mainCover" => String,
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
Koa.tags ["admin", "mangadex"]
|
|
||||||
get "/api/admin/mangadex/search" do |env|
|
|
||||||
begin
|
|
||||||
username = get_username env
|
|
||||||
token, expires = Storage.default.get_md_token username
|
|
||||||
|
|
||||||
unless expires && token
|
|
||||||
raise "No token found for user #{username}"
|
|
||||||
end
|
|
||||||
|
|
||||||
client = MangaDex::Client.from_config
|
|
||||||
client.token = token
|
|
||||||
client.token_expires = expires
|
|
||||||
|
|
||||||
query = env.params.query["query"]
|
|
||||||
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
"manga" => client.partial_search query,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
doc = Koa.generate
|
doc = Koa.generate
|
||||||
@@api_json = doc.to_json if doc
|
@@api_json = doc.to_json if doc
|
||||||
|
|
||||||
|
|||||||
+13
-8
@@ -41,7 +41,7 @@ struct MainRouter
|
|||||||
username = get_username env
|
username = get_username env
|
||||||
|
|
||||||
sort_opt = SortOptions.from_info_json Library.default.dir, username
|
sort_opt = SortOptions.from_info_json Library.default.dir, username
|
||||||
get_sort_opt
|
get_and_save_sort_opt Library.default.dir
|
||||||
|
|
||||||
titles = Library.default.sorted_titles username, sort_opt
|
titles = Library.default.sorted_titles username, sort_opt
|
||||||
percentage = titles.map &.load_percentage username
|
percentage = titles.map &.load_percentage username
|
||||||
@@ -59,12 +59,12 @@ struct MainRouter
|
|||||||
username = get_username env
|
username = get_username env
|
||||||
|
|
||||||
sort_opt = SortOptions.from_info_json title.dir, username
|
sort_opt = SortOptions.from_info_json title.dir, username
|
||||||
get_sort_opt
|
get_and_save_sort_opt title.dir
|
||||||
|
|
||||||
entries = title.sorted_entries username, sort_opt
|
entries = title.sorted_entries username, sort_opt
|
||||||
|
|
||||||
percentage = title.load_percentage_for_all_entries username, sort_opt
|
percentage = title.load_percentage_for_all_entries username, sort_opt
|
||||||
title_percentage = title.titles.map &.load_percentage username
|
title_percentage = title.titles.map &.load_percentage username
|
||||||
|
|
||||||
layout "title"
|
layout "title"
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
@@ -72,13 +72,18 @@ struct MainRouter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/download" do |env|
|
|
||||||
mangadex_base_url = Config.current.mangadex["base_url"]
|
|
||||||
layout "download"
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/download/plugins" do |env|
|
get "/download/plugins" do |env|
|
||||||
begin
|
begin
|
||||||
|
id = env.params.query["plugin"]?
|
||||||
|
plugins = Plugin.list
|
||||||
|
plugin = nil
|
||||||
|
|
||||||
|
if id
|
||||||
|
plugin = Plugin.new id
|
||||||
|
elsif !plugins.empty?
|
||||||
|
plugin = Plugin.new plugins[0][:id]
|
||||||
|
end
|
||||||
|
|
||||||
layout "plugin-download"
|
layout "plugin-download"
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
|
|||||||
+16
-3
@@ -428,12 +428,21 @@ class Storage
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def mark_unavailable
|
# Mark titles and entries that no longer exist on the file system as
|
||||||
|
# unavailable. By supplying `id_candidates` and `titles_candidates`, it
|
||||||
|
# only checks the existence of the candidate titles/entries to speed up
|
||||||
|
# the process.
|
||||||
|
def mark_unavailable(ids_candidates : Array(String)?,
|
||||||
|
titles_candidates : Array(String)?)
|
||||||
MainFiber.run do
|
MainFiber.run do
|
||||||
get_db do |db|
|
get_db do |db|
|
||||||
# Detect dangling entry IDs
|
# Detect dangling entry IDs
|
||||||
trash_ids = [] of String
|
trash_ids = [] of String
|
||||||
db.query "select path, id from ids where unavailable = 0" do |rs|
|
query = "select path, id from ids where unavailable = 0"
|
||||||
|
unless ids_candidates.nil?
|
||||||
|
query += " and id in (#{ids_candidates.join "," { |i| "'#{i}'" }})"
|
||||||
|
end
|
||||||
|
db.query query do |rs|
|
||||||
rs.each do
|
rs.each do
|
||||||
path = rs.read String
|
path = rs.read String
|
||||||
fullpath = Path.new(path).expand(Config.current.library_path).to_s
|
fullpath = Path.new(path).expand(Config.current.library_path).to_s
|
||||||
@@ -449,7 +458,11 @@ class Storage
|
|||||||
|
|
||||||
# Detect dangling title IDs
|
# Detect dangling title IDs
|
||||||
trash_titles = [] of String
|
trash_titles = [] of String
|
||||||
db.query "select path, id from titles where unavailable = 0" do |rs|
|
query = "select path, id from titles where unavailable = 0"
|
||||||
|
unless titles_candidates.nil?
|
||||||
|
query += " and id in (#{titles_candidates.join "," { |i| "'#{i}'" }})"
|
||||||
|
end
|
||||||
|
db.query query do |rs|
|
||||||
rs.each do
|
rs.each do
|
||||||
path = rs.read String
|
path = rs.read String
|
||||||
fullpath = Path.new(path).expand(Config.current.library_path).to_s
|
fullpath = Path.new(path).expand(Config.current.library_path).to_s
|
||||||
|
|||||||
@@ -48,4 +48,32 @@ class Dir
|
|||||||
end
|
end
|
||||||
Digest::CRC32.checksum(signatures.sort.join).to_u64
|
Digest::CRC32.checksum(signatures.sort.join).to_u64
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns the contents signature of the directory at dirname for checking
|
||||||
|
# to rescan.
|
||||||
|
# Rescan conditions:
|
||||||
|
# - When a file added, moved, removed, renamed (including which in nested
|
||||||
|
# directories)
|
||||||
|
def self.contents_signature(dirname, cache = {} of String => String) : String
|
||||||
|
return cache[dirname] if cache[dirname]?
|
||||||
|
Fiber.yield
|
||||||
|
signatures = [] of String
|
||||||
|
self.open dirname do |dir|
|
||||||
|
dir.entries.sort.each do |fn|
|
||||||
|
next if fn.starts_with? "."
|
||||||
|
path = File.join dirname, fn
|
||||||
|
if File.directory? path
|
||||||
|
signatures << Dir.contents_signature path, cache
|
||||||
|
else
|
||||||
|
# Only add its signature value to `signatures` when it is a
|
||||||
|
# supported file
|
||||||
|
signatures << fn if is_supported_file fn
|
||||||
|
end
|
||||||
|
Fiber.yield
|
||||||
|
end
|
||||||
|
end
|
||||||
|
hash = Digest::SHA1.hexdigest(signatures.join)
|
||||||
|
cache[dirname] = hash
|
||||||
|
hash
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ def register_mime_types
|
|||||||
# FontAwesome fonts
|
# FontAwesome fonts
|
||||||
".woff" => "font/woff",
|
".woff" => "font/woff",
|
||||||
".woff2" => "font/woff2",
|
".woff2" => "font/woff2",
|
||||||
|
|
||||||
|
# Supported image formats. JPG, PNG, GIF, WebP, and SVG are already
|
||||||
|
# defiend by Crystal in `MIME.DEFAULT_TYPES`
|
||||||
|
".apng" => "image/apng",
|
||||||
|
".avif" => "image/avif",
|
||||||
}.each do |k, v|
|
}.each do |k, v|
|
||||||
MIME.register k, v
|
MIME.register k, v
|
||||||
end
|
end
|
||||||
@@ -120,3 +125,22 @@ class String
|
|||||||
match / s.size
|
match / s.size
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Does the followings:
|
||||||
|
# - turns space-like characters into the normal whitespaces ( )
|
||||||
|
# - strips and collapses spaces
|
||||||
|
# - removes ASCII control characters
|
||||||
|
# - replaces slashes (/) with underscores (_)
|
||||||
|
# - removes leading dots (.)
|
||||||
|
# - removes the following special characters: \:*?"<>|
|
||||||
|
#
|
||||||
|
# If the sanitized string is empty, returns a random string instead.
|
||||||
|
def sanitize_filename(str : String) : String
|
||||||
|
sanitized = str
|
||||||
|
.gsub(/\s+/, " ")
|
||||||
|
.strip
|
||||||
|
.gsub(/\//, "_")
|
||||||
|
.gsub(/^[\.\s]+/, "")
|
||||||
|
.gsub(/[\177\000-\031\\:\*\?\"<>\|]/, "")
|
||||||
|
sanitized.size > 0 ? sanitized : random_str
|
||||||
|
end
|
||||||
|
|||||||
@@ -107,6 +107,26 @@ macro get_sort_opt
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
macro get_and_save_sort_opt(dir)
|
||||||
|
sort_method = env.params.query["sort"]?
|
||||||
|
|
||||||
|
if sort_method
|
||||||
|
is_ascending = true
|
||||||
|
|
||||||
|
ascend = env.params.query["ascend"]?
|
||||||
|
if ascend && ascend.to_i? == 0
|
||||||
|
is_ascending = false
|
||||||
|
end
|
||||||
|
|
||||||
|
sort_opt = SortOptions.new sort_method, is_ascending
|
||||||
|
|
||||||
|
TitleInfo.new {{dir}} do |info|
|
||||||
|
info.sort_by[username] = sort_opt.to_tuple
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
module HTTP
|
module HTTP
|
||||||
class Client
|
class Client
|
||||||
private def self.exec(uri : URI, tls : TLSContext = nil)
|
private def self.exec(uri : URI, tls : TLSContext = nil)
|
||||||
|
|||||||
@@ -33,7 +33,6 @@
|
|||||||
<option>System</option>
|
<option>System</option>
|
||||||
</select>
|
</select>
|
||||||
</li>
|
</li>
|
||||||
<li><a class="uk-link-reset" href="<%= base_url %>admin/mangadex">Connect to MangaDex</a></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<hr class="uk-divider-icon">
|
<hr class="uk-divider-icon">
|
||||||
|
|||||||
@@ -5,63 +5,61 @@
|
|||||||
<button class="uk-button uk-button-default" @click="load()" :disabled="loading">Refresh Queue</button>
|
<button class="uk-button uk-button-default" @click="load()" :disabled="loading">Refresh Queue</button>
|
||||||
<button class="uk-button uk-button-default" x-show="paused !== undefined" x-text="paused ? 'Resume Download' : 'Pause Download'" @click="toggle()" :disabled="toggling"></button>
|
<button class="uk-button uk-button-default" x-show="paused !== undefined" x-text="paused ? 'Resume Download' : 'Pause Download'" @click="toggle()" :disabled="toggling"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-overflow-auto">
|
<table class="uk-table uk-table-striped uk-overflow-auto">
|
||||||
<table class="uk-table uk-table-striped">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th>Chapter</th>
|
||||||
<th>Chapter</th>
|
<th>Manga</th>
|
||||||
<th>Manga</th>
|
<th>Progress</th>
|
||||||
<th>Progress</th>
|
<th>Time</th>
|
||||||
<th>Time</th>
|
<th>Status</th>
|
||||||
<th>Status</th>
|
<th>Plugin</th>
|
||||||
<th>Plugin</th>
|
<th>Actions</th>
|
||||||
<th>Actions</th>
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="job in jobs" :key="job">
|
||||||
|
<tr :id="`chapter-${job.id}`">
|
||||||
|
|
||||||
|
<template x-if="job.plugin_id">
|
||||||
|
<td x-text="job.title"></td>
|
||||||
|
</template>
|
||||||
|
<template x-if="!job.plugin_id">
|
||||||
|
<td><a :href="`<%= mangadex_base_url %>/chapter/${job.id}`" x-text="job.title"></td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="job.plugin_id">
|
||||||
|
<td x-text="job.manga_title"></td>
|
||||||
|
</template>
|
||||||
|
<template x-if="!job.plugin_id">
|
||||||
|
<td><a :href="`<%= mangadex_base_url %>/manga/${job.manga_id}`" x-text="job.manga_title"></td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<td x-text="`${job.success_count}/${job.pages}`"></td>
|
||||||
|
<td x-text="`${moment(job.time).fromNow()}`"></td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span :class="statusClass(job.status)" x-text="job.status"></span>
|
||||||
|
<template x-if="job.status_message.length > 0">
|
||||||
|
<div class="uk-inline">
|
||||||
|
<span uk-icon="info"></span>
|
||||||
|
<div uk-dropdown x-text="job.status_message" style="white-space: pre-line;"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td x-text="`${job.plugin_id || ''}`"></td>
|
||||||
|
<td>
|
||||||
|
<a @click="jobAction('delete', $event)" uk-icon="trash" uk-tooltip="Delete"></a>
|
||||||
|
<template x-if="job.status_message.length > 0">
|
||||||
|
<a @click="jobAction('retry', $event)" uk-icon="refresh" uk-tooltip="Retry"></a>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</template>
|
||||||
<tbody>
|
</tbody>
|
||||||
<template x-for="job in jobs" :key="job">
|
</table>
|
||||||
<tr :id="`chapter-${job.id}`">
|
</div>
|
||||||
|
|
||||||
<template x-if="job.plugin_id">
|
|
||||||
<td x-text="job.title"></td>
|
|
||||||
</template>
|
|
||||||
<template x-if="!job.plugin_id">
|
|
||||||
<td><a :href="`<%= mangadex_base_url %>/chapter/${job.id}`" x-text="job.title"></td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="job.plugin_id">
|
|
||||||
<td x-text="job.manga_title"></td>
|
|
||||||
</template>
|
|
||||||
<template x-if="!job.plugin_id">
|
|
||||||
<td><a :href="`<%= mangadex_base_url %>/manga/${job.manga_id}`" x-text="job.manga_title"></td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<td x-text="`${job.success_count}/${job.pages}`"></td>
|
|
||||||
<td x-text="`${moment(job.time).fromNow()}`"></td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<span :class="statusClass(job.status)" x-text="job.status"></span>
|
|
||||||
<template x-if="job.status_message.length > 0">
|
|
||||||
<div class="uk-inline">
|
|
||||||
<span uk-icon="info"></span>
|
|
||||||
<div uk-dropdown x-text="job.status_message" style="white-space: pre-line;"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td x-text="`${job.plugin_id || ''}`"></td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<a @click="jobAction('delete', $event)" uk-icon="trash"></a>
|
|
||||||
<template x-if="job.status_message.length > 0">
|
|
||||||
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
|
|
||||||
</template>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
|
|||||||
+77
-79
@@ -1,89 +1,87 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
<%= render_component "head" %>
|
<%= render_component "head" %>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="uk-offcanvas-content">
|
<div class="uk-offcanvas-content">
|
||||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||||
<div id="mobile-nav" uk-offcanvas="overlay: true">
|
<div id="mobile-nav" uk-offcanvas="overlay: true">
|
||||||
<div class="uk-offcanvas-bar uk-flex uk-flex-column">
|
<div class="uk-offcanvas-bar uk-flex uk-flex-column">
|
||||||
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
|
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
|
||||||
<li><a href="<%= base_url %>">Home</a></li>
|
<li><a href="<%= base_url %>">Home</a></li>
|
||||||
<li><a href="<%= base_url %>library">Library</a></li>
|
<li><a href="<%= base_url %>library">Library</a></li>
|
||||||
<li><a href="<%= base_url %>tags">Tags</a></li>
|
<li><a href="<%= base_url %>tags">Tags</a></li>
|
||||||
<% if is_admin %>
|
<% if is_admin %>
|
||||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||||
<li class="uk-parent">
|
<li class="uk-parent">
|
||||||
<a href="#">Download</a>
|
<a href="#">Download</a>
|
||||||
<ul class="uk-nav-sub">
|
<ul class="uk-nav-sub">
|
||||||
<li><a href="<%= base_url %>download">MangaDex</a></li>
|
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
||||||
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
</ul>
|
||||||
</ul>
|
</li>
|
||||||
</li>
|
<% end %>
|
||||||
<% end %>
|
<hr uk-divider>
|
||||||
<hr uk-divider>
|
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
<li><a href="<%= base_url %>logout">Logout</a></li>
|
||||||
<li><a href="<%= base_url %>logout">Logout</a></li>
|
</ul>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-position-top">
|
</div>
|
||||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
</div>
|
||||||
<div class="uk-navbar-left uk-hidden@s">
|
<div class="uk-position-top">
|
||||||
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||||
</div>
|
<div class="uk-navbar-left uk-hidden@s">
|
||||||
<div class="uk-navbar-left uk-visible@s">
|
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
||||||
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png" style="width:90px;height:90px;"></a>
|
|
||||||
<ul class="uk-navbar-nav">
|
|
||||||
<li><a href="<%= base_url %>">Home</a></li>
|
|
||||||
<li><a href="<%= base_url %>library">Library</a></li>
|
|
||||||
<li><a href="<%= base_url %>tags">Tags</a></li>
|
|
||||||
<% if is_admin %>
|
|
||||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
|
||||||
<li>
|
|
||||||
<a href="#">Download</a>
|
|
||||||
<div class="uk-navbar-dropdown">
|
|
||||||
<ul class="uk-nav uk-navbar-dropdown-nav">
|
|
||||||
<li class="uk-nav-header">Source</li>
|
|
||||||
<li><a href="<%= base_url %>download">MangaDex</a></li>
|
|
||||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
|
||||||
<li class="uk-nav-divider"></li>
|
|
||||||
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="uk-navbar-right uk-visible@s">
|
|
||||||
<ul class="uk-navbar-nav">
|
|
||||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
|
||||||
<li><a href="<%= base_url %>logout">Logout</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-section uk-section-small">
|
<div class="uk-navbar-left uk-visible@s">
|
||||||
</div>
|
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png" style="width:90px;height:90px;"></a>
|
||||||
<div class="uk-section uk-section-small" style="position:relative;">
|
<ul class="uk-navbar-nav">
|
||||||
<div class="uk-container uk-container-small">
|
<li><a href="<%= base_url %>">Home</a></li>
|
||||||
<div id="alert"></div>
|
<li><a href="<%= base_url %>library">Library</a></li>
|
||||||
<%= content %>
|
<li><a href="<%= base_url %>tags">Tags</a></li>
|
||||||
<div class="uk-visible@m" id="totop-wrapper" x-data="{}" x-show="$('body').height() > 1.5 * $(window).height()">
|
<% if is_admin %>
|
||||||
<a href="#" uk-totop uk-scroll></a>
|
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||||
|
<li>
|
||||||
|
<a href="#">Download</a>
|
||||||
|
<div class="uk-navbar-dropdown">
|
||||||
|
<ul class="uk-nav uk-navbar-dropdown-nav">
|
||||||
|
<li class="uk-nav-header">Source</li>
|
||||||
|
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||||
|
<li class="uk-nav-divider"></li>
|
||||||
|
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<div class="uk-navbar-right uk-visible@s">
|
||||||
setTheme();
|
<ul class="uk-navbar-nav">
|
||||||
const base_url = "<%= base_url %>";
|
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||||
</script>
|
<li><a href="<%= base_url %>logout">Logout</a></li>
|
||||||
<%= render_component "uikit" %>
|
</ul>
|
||||||
<%= yield_content "script" %>
|
</div>
|
||||||
</body>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-section uk-section-small">
|
||||||
|
</div>
|
||||||
|
<div class="uk-section uk-section-small" style="position:relative;">
|
||||||
|
<div class="uk-container uk-container-small">
|
||||||
|
<div id="alert"></div>
|
||||||
|
<%= content %>
|
||||||
|
<div class="uk-visible@m" id="totop-wrapper" x-data="{}" x-show="$('body').height() > 1.5 * $(window).height()">
|
||||||
|
<a href="#" uk-totop uk-scroll></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
setTheme();
|
||||||
|
const base_url = "<%= base_url %>";
|
||||||
|
</script>
|
||||||
|
<%= render_component "uikit" %>
|
||||||
|
<%= yield_content "script" %>
|
||||||
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,36 +3,34 @@
|
|||||||
<div x-show="!empty">
|
<div x-show="!empty">
|
||||||
<p>The following items were present in your library, but now we can't find them anymore. If you deleted them mistakenly, try to recover the files or folders, put them back to where they were, and rescan the library. Otherwise, you can safely delete them and the associated metadata using the buttons below to free up database space.</p>
|
<p>The following items were present in your library, but now we can't find them anymore. If you deleted them mistakenly, try to recover the files or folders, put them back to where they were, and rescan the library. Otherwise, you can safely delete them and the associated metadata using the buttons below to free up database space.</p>
|
||||||
<button class="uk-button uk-button-danger" @click="rmAll()">Delete All</button>
|
<button class="uk-button uk-button-danger" @click="rmAll()">Delete All</button>
|
||||||
<div class="uk-overflow-auto">
|
<table class="uk-table uk-table-striped uk-overflow-auto">
|
||||||
<table class="uk-table uk-table-striped">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th>Type</th>
|
||||||
<th>Type</th>
|
<th>Relative Path</th>
|
||||||
<th>Relative Path</th>
|
<th>ID</th>
|
||||||
<th>ID</th>
|
<th>Actions</th>
|
||||||
<th>Actions</th>
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="title in titles" :key="title">
|
||||||
|
<tr :id="`title-${title.id}`">
|
||||||
|
<td>Title</td>
|
||||||
|
<td x-text="title.path"></td>
|
||||||
|
<td x-text="title.id"></td>
|
||||||
|
<td><a @click="rm($event)" uk-icon="trash"></a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</template>
|
||||||
<tbody>
|
<template x-for="entry in entries" :key="entry">
|
||||||
<template x-for="title in titles" :key="title">
|
<tr :id="`entry-${entry.id}`">
|
||||||
<tr :id="`title-${title.id}`">
|
<td>Entry</td>
|
||||||
<td>Title</td>
|
<td x-text="entry.path"></td>
|
||||||
<td x-text="title.path"></td>
|
<td x-text="entry.id"></td>
|
||||||
<td x-text="title.id"></td>
|
<td><a @click="rm($event)" uk-icon="trash"></a></td>
|
||||||
<td><a @click="rm($event)" uk-icon="trash"></a></td>
|
</tr>
|
||||||
</tr>
|
</template>
|
||||||
</template>
|
</tbody>
|
||||||
<template x-for="entry in entries" :key="entry">
|
</table>
|
||||||
<tr :id="`entry-${entry.id}`">
|
|
||||||
<td>Entry</td>
|
|
||||||
<td x-text="entry.path"></td>
|
|
||||||
<td x-text="entry.id"></td>
|
|
||||||
<td><a @click="rm($event)" uk-icon="trash"></a></td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,180 +1,77 @@
|
|||||||
<div x-data="component()" x-init="init()" x-cloak>
|
<% if plugins.empty? %>
|
||||||
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
|
<div class="uk-container uk-text-center">
|
||||||
<div class="uk-container uk-text-center" x-show="plugins.length === 0" style="width:100%">
|
<h2>No Plugins Found</h2>
|
||||||
<h2>No Plugins Found</h2>
|
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
|
||||||
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
|
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
|
||||||
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
|
</div>
|
||||||
|
|
||||||
|
<% else %>
|
||||||
|
<h2 class=uk-title>Download with Plugins</h2>
|
||||||
|
|
||||||
|
<div id="controls" class="uk-grid-small" uk-grid hidden>
|
||||||
|
<div class="uk-width-3-4@m uk-child-width-1-1">
|
||||||
|
<div class="uk-margin">
|
||||||
|
<label class="uk-form-label" for="search-input"> </label>
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<input id="search-input" class="uk-input" type="text" placeholder="<%= plugin.not_nil!.info.placeholder %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="uk-width-expand">
|
||||||
<div x-show="plugins.length > 0" style="width:100%">
|
<div class="uk-margin">
|
||||||
<h2 class=uk-title>Download with Plugins
|
<label class="uk-form-label" for="plugin-select">Choose a plugin</label>
|
||||||
<span x-show="searching" uk-spinner class="uk-margin-left"></span>
|
<div class="uk-form-controls">
|
||||||
</h2>
|
<select id="plugin-select" class="uk-select">
|
||||||
|
<% plugins.each do |p| %>
|
||||||
<template x-if="info !== undefined">
|
<option value="<%= p[:id] %>"><%= p[:title] %></option>
|
||||||
<div>
|
<% end %>
|
||||||
<div class="uk-grid-small" uk-grid>
|
</select>
|
||||||
<div class="uk-width-3-4@m uk-child-width-1-1">
|
|
||||||
<div class="uk-margin">
|
|
||||||
<div class="uk-form-controls">
|
|
||||||
<label class="uk-form-label"> </label>
|
|
||||||
<input class="uk-input" type="text" :placeholder="info.placeholder" x-model="query" @keydown.enter="search()">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="uk-width-expand">
|
|
||||||
<div class="uk-margin">
|
|
||||||
<label class="uk-form-label">Choose a plugin</label>
|
|
||||||
<div class="uk-form-controls">
|
|
||||||
<select class="uk-select" x-model="pid" @change="pluginChanged()">
|
|
||||||
<template x-for="p in plugins" :key="p">
|
|
||||||
<option :value="p.id" x-text="p.title"></option>
|
|
||||||
</template>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="uk-width-auto">
|
|
||||||
<div class="uk-margin">
|
|
||||||
<label class="uk-form-label"> </label>
|
|
||||||
<div class="uk-form-controls" style="padding-top: 10px;">
|
|
||||||
<span uk-icon="info" uk-toggle="target: #toggle"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template x-for="entry, idx in Object.entries(info).filter(tp => !['id', 'settings'].includes(tp[0]))" :key="idx">
|
|
||||||
<dl class="uk-description-list" id="toggle" hidden>
|
|
||||||
<dt x-text="entry[0]"></dt>
|
|
||||||
<dd x-text="entry[1]"></dd>
|
|
||||||
</dl>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
</div>
|
||||||
<template x-if="manga">
|
<div class="uk-width-auto">
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<p x-show="manga.length === 0">No matching manga found.</p>
|
<label class="uk-form-label" for="search-input"> </label>
|
||||||
<p x-show="manga.length > 0">
|
<div class="uk-form-controls" style="padding-top: 10px;">
|
||||||
<span x-text="`${manga.length} manga found`"></span>
|
<span uk-icon="info" uk-toggle="target: #toggle"></span>
|
||||||
<span :uk-icon="listManga ? 'chevron-down' : 'chevron-right'" @click="listManga = !listManga"></span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid x-show="listManga">
|
|
||||||
<template x-for="m in manga" :key="m.id">
|
|
||||||
<div class="item" :data-id="m.id" @click="mangaSelected($event)">
|
|
||||||
<div class="uk-card uk-card-default">
|
|
||||||
<div class="uk-card-media-top uk-inline">
|
|
||||||
<img uk-img :data-src="m.cover_url">
|
|
||||||
</div>
|
|
||||||
<div class="uk-card-body">
|
|
||||||
<h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="m.title"></h3>
|
|
||||||
<p class="uk-text-meta" x-text="`ID: ${m.id}`"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="uk-margin-large-top" x-show="chapters !== undefined">
|
|
||||||
<h3 x-text="mangaTitle"></h3>
|
|
||||||
<p x-text="`${chapters ? chapters.length : 0} chapters found`"></p>
|
|
||||||
|
|
||||||
<div class="uk-margin">
|
|
||||||
<div x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
|
|
||||||
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
|
|
||||||
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
|
|
||||||
<button class="uk-button uk-button-primary" @click="download()">Download Selected</button>
|
|
||||||
<button class="uk-icon-button uk-margin-small-left" uk-icon="settings" @click="showFilters = !showFilters"></button>
|
|
||||||
</div>
|
|
||||||
<div uk-spinner class="uk-margin-left" x-show="adding"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form x-show="showFilters || (chapters && chapters.length > chaptersLimit)" class="uk-form-stacked uk-margin-bottom" id="filter-form">
|
|
||||||
<template x-for="field in filters">
|
|
||||||
<div class="uk-margin">
|
|
||||||
<label class="uk-form-label">
|
|
||||||
<span x-text="field.key"></span>
|
|
||||||
<template x-if="field.type === 'number'">
|
|
||||||
<span class="uk-text-meta" x-text="`(between ${Math.min(...field.values)} and ${Math.max(...field.values)})`"></span>
|
|
||||||
</template>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div x-show="field.type === 'number'" class="uk-grid-small" uk-grid>
|
|
||||||
<div class="uk-width-1-2@s">
|
|
||||||
<input class="uk-input" placeholder="minimum value" :data-filter-key="field.key" data-filter-type="number-min">
|
|
||||||
</div>
|
|
||||||
<div class="uk-width-1-2@s">
|
|
||||||
<input class="uk-input" placeholder="maximum value" :data-filter-key="field.key" data-filter-type="number-max">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div x-show="field.type === 'date'" class="uk-grid-small" uk-grid>
|
|
||||||
<div class="uk-width-1-2@s">
|
|
||||||
<input class="uk-input" placeholder="minimum date, e.g., Jan 1 1970" :data-filter-key="field.key" data-filter-type="date-min">
|
|
||||||
</div>
|
|
||||||
<div class="uk-width-1-2@s">
|
|
||||||
<input class="uk-input" placeholder="maximum date, e.g., Jan 1 1970" :data-filter-key="field.key" data-filter-type="date-max">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input x-show="field.type === 'string'" class="uk-input" placeholder="filter text" :data-filter-key="field.key" data-filter-type="string">
|
|
||||||
|
|
||||||
<select class="uk-select" x-show="field.type === 'array'" :data-filter-key="field.key" data-filter-type="array">
|
|
||||||
<option value="all">All</option>
|
|
||||||
<template x-for="v in field.values" :key="v">
|
|
||||||
<option x-text="v" :value="v"></option>
|
|
||||||
</template>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<button class="uk-button uk-button-primary" @click.prevent="applyFilters()">Apply</button>
|
|
||||||
<button class="uk-button uk-button-default" @click.prevent="clearFilters()">Clear</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p class="uk-text-meta" x-show="chapters && chapters.length > chaptersLimit" x-text="`The manga has ${chapters ? chapters.length : 0} chapters, but Mango can only list up to ${chaptersLimit}. Please use the filters to narrow down your search.`"></p>
|
|
||||||
<p x-show="chapters && chapters.length === 0" class="uk-text-meta">No chapters found.</p>
|
|
||||||
|
|
||||||
<div x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
|
|
||||||
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
|
||||||
<div class="uk-overflow-auto">
|
|
||||||
<table class="uk-table uk-table-striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<template x-for="(k, idx) in chapterKeys" :key="k">
|
|
||||||
<th :id="`th-${idx}`" @click="thClicked($event)">
|
|
||||||
<span x-text="k"></span>
|
|
||||||
<i class="fas fa-sort" x-show="![1, -1].includes(sortOptions[idx])"></i>
|
|
||||||
<i class="fas fa-sort-up" x-show="sortOptions[idx] === 1"></i>
|
|
||||||
<i class="fas fa-sort-down" x-show="sortOptions[idx] === -1"></i>
|
|
||||||
</th>
|
|
||||||
</template>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="selectable">
|
|
||||||
<template x-if="chapters !== undefined && chapters.length < chaptersLimit">
|
|
||||||
<template x-for="ch in chapters" :key="ch">
|
|
||||||
<tr class="ui-widget-content" :id="ch.id">
|
|
||||||
<template x-for="k in chapterKeys" :key="k">
|
|
||||||
<td x-text="ch[k]"></td>
|
|
||||||
</template>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<dl class="uk-description-list" id="toggle" hidden>
|
||||||
|
<% plugin.not_nil!.info.each do |k, v| %>
|
||||||
|
<dt><%= k %></dt>
|
||||||
|
<dd><%= v.to_s %></dd>
|
||||||
|
<% end %>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div id="table" class="uk-margin-large-top" hidden>
|
||||||
|
<h3 id="title-text"></h3>
|
||||||
|
|
||||||
|
<div class="uk-margin">
|
||||||
|
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
|
||||||
|
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
||||||
|
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
||||||
|
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
||||||
|
</div>
|
||||||
|
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
||||||
|
<div class="uk-overflow-auto">
|
||||||
|
<table class="uk-table uk-table-striped tablesorter">
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
|
<% if plugin %>
|
||||||
|
<script>
|
||||||
|
var pid = "<%= plugin.not_nil!.info.id %>";
|
||||||
|
</script>
|
||||||
|
<% end %>
|
||||||
<%= render_component "jquery-ui" %>
|
<%= render_component "jquery-ui" %>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/js/jquery.tablesorter.combined.min.js"></script>
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
<script src="<%= base_url %>js/plugin-download.js"></script>
|
<script src="<%= base_url %>js/plugin-download.js"></script>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<div
|
<div
|
||||||
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
|
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
|
||||||
<div x-show="!loading && mode === 'continuous'" x-cloak>
|
<div x-show="!loading && mode === 'continuous'" x-cloak>
|
||||||
<template x-for="item in items">
|
<template x-if="!loading && mode === 'continuous'" x-for="item in items">
|
||||||
<img
|
<img
|
||||||
uk-img
|
uk-img
|
||||||
:class="{'uk-align-center': true, 'spine': item.width < 50}"
|
:class="{'uk-align-center': true, 'spine': item.width < 50}"
|
||||||
@@ -50,6 +50,9 @@
|
|||||||
width:${mode === 'width' ? '100vw' : 'auto'};
|
width:${mode === 'width' ? '100vw' : 'auto'};
|
||||||
height:${mode === 'height' ? '100vh' : 'auto'};
|
height:${mode === 'height' ? '100vh' : 'auto'};
|
||||||
margin-bottom:0;
|
margin-bottom:0;
|
||||||
|
max-width:100%;
|
||||||
|
max-height:100%;
|
||||||
|
object-fit: contain;
|
||||||
`" />
|
`" />
|
||||||
|
|
||||||
<div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false)"></div>
|
<div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false)"></div>
|
||||||
@@ -98,6 +101,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="uk-margin uk-form-horizontal" x-show="mode !== 'continuous'">
|
||||||
|
<label class="uk-form-label" for="enable-flip-animation">Enable Flip Animation</label>
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<input id="enable-flip-animation" class="uk-checkbox" type="checkbox" x-model="enableFlipAnimation" @change="enableFlipAnimationChanged()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-margin uk-form-horizontal" x-show="mode !== 'continuous'">
|
||||||
|
<label class="uk-form-label" for="preload-lookahead" x-text="`Preload Image: ${preloadLookahead} page(s)`"></label>
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<input id="preload-lookahead" class="uk-range" type="range" min="0" max="5" step="1" x-model.number="preloadLookahead" @change="preloadLookaheadChanged()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr class="uk-divider-icon">
|
<hr class="uk-divider-icon">
|
||||||
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
|
|||||||
Reference in New Issue
Block a user