Compare commits

..

95 Commits

Author SHA1 Message Date
Alex Ling 8d84a3c502 Merge pull request #221 from hkalexling/rc/0.23.0
v0.23.0
2021-08-28 18:44:50 +08:00
Alex Ling a26b4b3965 Add Discord badge 2021-08-28 10:27:43 +00:00
Alex Ling f2dd20cdec Bump version to 0.23.0 2021-08-22 08:20:24 +00:00
Alex Ling 64d6cd293c Remove MangaDex from README 2021-08-22 08:20:01 +00:00
Alex Ling 08dc0601e8 Remove page_margin from README 2021-08-22 08:18:28 +00:00
Alex Ling 9c983df7e9 Merge pull request #216 from Leeingnyo/feature/update-crystal-1.0
Update crystal version to 1.0
2021-08-22 15:59:20 +08:00
Alex Ling efc547f5b2 Fix "Executing query" spam in log 2021-08-22 07:34:29 +00:00
Alex Ling 995ca3b40f Update ARM Dockerfile 2021-08-20 08:26:11 +00:00
Alex Ling 864435d3f9 Upgrade dependencies for Crystal 1.0.0 2021-08-20 07:15:16 +00:00
Alex Ling 64c145cf80 Merge branch 'dev' of https://github.com/hkalexling/Mango into feature/update-crystal-1.0 2021-08-20 00:36:38 +00:00
Alex Ling 6549253ed1 Merge branch 'dev' of https://github.com/hkalexling/Mango into dev 2021-08-20 00:26:59 +00:00
Alex Ling d9565718a4 Remove MangaDex integration 2021-08-20 00:25:21 +00:00
Alex Ling 400c3024fd Merge pull request #219 from Leeingnyo/feature/enhance-paged-mode
Enhance the paged mode reader
2021-08-20 08:13:39 +08:00
Leeingnyo a703175b3a Parenthesize to avoid a misbehavior 2021-08-19 15:03:01 +09:00
Leeingnyo 83b122ab75 Implement a controller to toggle flip animation 2021-08-18 23:46:45 +09:00
Leeingnyo 1e7d6ba5b1 Keep an image ratio at the paged mode, clamping the image in a screen 2021-08-18 23:46:45 +09:00
Leeingnyo 4d1ad8fb38 Prevent load images 2021-08-18 23:46:45 +09:00
Leeingnyo d544252e3e Add preload lookahead controller 2021-08-18 23:46:45 +09:00
Leeingnyo b02b28d3e3 Implement to preload images when viewer is paged mode 2021-08-18 23:07:38 +09:00
Leeingnyo d7efe1e553 Use yaml-static in Dockerfile 2021-08-18 21:23:28 +09:00
Alex Ling 1973564272 Revert "Subscription manager"
This reverts commit a612500b0f.
2021-08-18 12:09:59 +00:00
Alex Ling 29923f6dc7 Merge pull request #214 from Leeingnyo/feature/http-cache
HTTP cache for thumbnails, pages, dimensions
2021-08-18 07:37:21 +08:00
Leeingnyo 4a261d5ff8 Set Cache-Control header at page, dimensions API 2021-08-18 01:33:22 +09:00
Alex Ling 31d425d462 Document the 304 responses 2021-08-17 07:14:24 +00:00
Leeingnyo a21681a6d7 Update a container version of bulid workflow 2021-08-16 13:23:58 +09:00
Leeingnyo 208019a0b9 Update Docker image 2021-08-16 01:48:18 +09:00
Leeingnyo 54e2a54ecb Update cyrstal-lang, libraries 2021-08-16 01:48:18 +09:00
Leeingnyo 2426ef05ec Apply cache on dimensions api
Use zip_path and mtime for hashing
It used for weak validation
2021-08-15 21:41:44 +09:00
Leeingnyo 25b90a8724 Apply cache on page, cover api
Get image data and use it for hashing
2021-08-15 21:33:08 +09:00
Alex Ling cd8944ed2d Slim option in library and title APIs 2021-04-25 12:41:37 +00:00
Alex Ling 7f0c256fe6 Log login errors 2021-04-25 12:41:29 +00:00
Alex Ling 46e6e41bfe Fix reader buttons stacking on mobile 2021-03-29 00:41:33 +00:00
Alex Ling c9f55e7a8e Use yaml-static 2021-03-28 12:49:50 +00:00
Alex Ling 741c3a4e20 Update config example in README 2021-03-28 11:56:06 +00:00
Alex Ling f6da20321d Bump version to 0.22.0 2021-03-28 11:49:49 +00:00
Alex Ling 2764e955b2 Show success alert on plugin download page 2021-03-15 17:07:15 +00:00
Alex Ling 00c15014a1 Document subscription APIs 2021-03-15 07:12:30 +00:00
Alex Ling c6fdbfd9fd Better format ranges on subscription manager page 2021-03-15 07:12:10 +00:00
Alex Ling e03bf32358 Show success alerts on the download page 2021-03-14 17:36:43 +00:00
Alex Ling bbf1520c73 Make in_range? private 2021-03-14 17:36:26 +00:00
Alex Ling 8950c3a1ed Fix downloader stuck on external chapters 2021-03-14 16:27:08 +00:00
Alex Ling 17837d8a29 Add tooltips to download manager 2021-03-14 16:03:37 +00:00
Alex Ling b4a69425c8 Reverse the queue on download manager 2021-03-14 16:01:29 +00:00
Alex Ling a612500b0f Subscription manager 2021-03-14 16:01:29 +00:00
Alex Ling 9bb7144479 Fix warning 2021-03-12 15:28:39 +00:00
Alex Ling ee52c52f46 Fix new linter errors 2021-03-12 15:03:12 +00:00
Alex Ling daec2bdac6 Update ameba 2021-03-12 14:06:20 +00:00
Alex Ling e9a490676b Update the mangadex shard 2021-03-12 13:59:11 +00:00
Alex Ling 757f7c8214 Upgrade Crystal to 0.36.1 2021-03-12 13:41:24 +00:00
Alex Ling eed1a9717e Merge branch 'master' into dev 2021-03-10 16:48:51 +00:00
Alex Ling 8829d2e237 Merge pull request #173 from hkalexling/rc/0.21.0 2021-03-11 00:44:49 +08:00
Alex Ling eec6ec60bf Warn about old API url (#174) 2021-03-10 05:47:25 +00:00
Alex Ling 3a82effa40 Update config in README 2021-03-09 18:01:03 +00:00
Alex Ling 0b3e78bcb7 Merge branch 'rc/0.21.0' into dev 2021-03-09 16:45:26 +00:00
Alex Ling cb4e4437a6 Update MD API URL (closes #174) 2021-03-09 16:43:46 +00:00
Alex Ling 6a275286ea Merge branch 'rc/0.21.0' into dev 2021-03-07 14:14:46 +00:00
Alex Ling 2743868438 Remove outdated MD API link in warning 2021-03-06 17:03:48 +00:00
Alex Ling d3f26ecbc9 Move the page margin config to frontend 2021-03-06 15:04:44 +00:00
Alex Ling f62344806a Bump version to 0.21.0 2021-03-06 06:16:07 +00:00
Alex Ling b7b7e6f718 Fix typo [skip ci] 2021-03-05 17:04:23 +00:00
Alex Ling 05b4e77fa9 Entry selector on reader page (closes #168) 2021-03-05 17:02:45 +00:00
Alex Ling 8aab113aab Expiration date should be nil when theres no token 2021-03-05 11:01:00 +00:00
Alex Ling 371c8056e7 Wording 2021-03-05 10:57:23 +00:00
Alex Ling a9a2c9faa8 Finish search for MD 2021-03-05 04:58:56 +00:00
Alex Ling 011768ed1f Rename the dots-scripts component to dots 2021-03-05 04:58:56 +00:00
Alex Ling c36d2608e8 Make uk-card adaptive to dark/light mode 2021-03-05 04:58:56 +00:00
Alex Ling 1b25a1fa47 Update Koa 2021-03-05 04:58:56 +00:00
Alex Ling df7e2270a4 Add MangaDex login page 2021-03-05 04:58:56 +00:00
Alex Ling 3c3549a489 Merge pull request #172 from hkalexling/hotfix/bind-localhost 2021-03-04 13:47:59 +08:00
Alex Ling 8160b0a18e Bump version to 0.20.2 2021-03-04 04:49:37 +00:00
Alex Ling a7eff772be Update example config in README 2021-03-04 04:48:51 +00:00
Alex Ling bf3900f9a2 Add host to config 2021-03-03 17:35:39 +00:00
Alex Ling 6fa575cf4f Bind localhost when a proxy auth header is set 2021-03-03 16:28:31 +00:00
Alex Ling 604c5d49a6 Merge pull request #166 from hkalexling/dev 2021-02-28 19:38:02 +08:00
Alex Ling 7449d19075 Bump version to 0.20.1 2021-02-26 10:35:34 +00:00
Alex Ling c5c9305a0b Merge pull request #162 from hkalexling/all-contributors/add-davidkna
docs: add davidkna as a contributor
2021-02-14 23:30:02 +08:00
allcontributors[bot] fdceab9060 docs: update .all-contributorsrc [skip ci] 2021-02-14 15:28:33 +00:00
allcontributors[bot] c18591c5cf docs: update README.md [skip ci] 2021-02-14 15:28:32 +00:00
Alex Ling bb5cb9b94c Merge pull request #161 from davidkna/docker-usr-local
Move binary in docker image to /usr/local
2021-02-14 23:26:38 +08:00
David Knaack fb499a5caf Move binary in docker image to /usr/local 2021-02-14 11:42:00 +01:00
Alex Ling 154d85e197 Use only woff and woff2 2021-02-11 08:40:24 +00:00
Alex Ling 933617503e Optimize the static files
- Use webfont version of FontAwesome
- Use CDN for UIKit JS files
2021-02-10 16:24:34 +00:00
Alex Ling 31c6893bbb Display book spines in original size (fixes #152) 2021-02-06 13:37:25 +00:00
Alex Ling 171125e8ac Merge pull request #159 from Leeingnyo/fix/favicon-500-error
Fix HTTP 500 Error when accessing the favicon
2021-02-06 16:34:56 +08:00
Leeingnyo d81334026b add MIME type of ico file
The server returns 500 error when requested '/favion.ico'
The handler worked fine, but send_file has failed with
- Missing MIME type for extension ".ico"
so I register mime type for .ico file
2021-02-06 16:58:49 +09:00
Alex Ling 2b3b2eb8ba Fill default configs before pre-processing 2021-02-03 05:27:41 +00:00
Alex Ling ffd5f4454b Merge branch 'feature/auth-proxy' into dev 2021-02-03 05:23:00 +00:00
Alex Ling cb25d7ba00 Merge branch 'feature/mangadex-api-upgrade' into dev 2021-02-03 05:22:35 +00:00
Alex Ling c61eb7554e Update the mangadex shard 2021-02-01 11:35:16 +00:00
Alex Ling edd9a2e093 Add MutationObserver polyfill 2021-01-31 15:32:38 +00:00
Alex Ling 1f50785e8f Rewrite MangaDex download page with Alpine 2021-01-31 12:48:37 +00:00
Alex Ling 70d418d1a1 Upgrade to MangaDex API v2 2021-01-30 17:08:04 +00:00
Alex Ling 45e20c94f9 Merge branch 'dev' into feature/auth-proxy 2021-01-30 10:55:27 +00:00
Alex Ling 4da263c594 Rewrite auth_handler
Make sure the OPDS pages are accessible without login when login is
disabled
2021-01-30 10:54:03 +00:00
Alex Ling d67a24809b Allow proxy authentication (#141) 2021-01-30 07:43:02 +00:00
60 changed files with 1142 additions and 1307 deletions
+9
View File
@@ -95,6 +95,15 @@
"contributions": [ "contributions": [
"code" "code"
] ]
},
{
"login": "davidkna",
"name": "David Knaack",
"avatar_url": "https://avatars.githubusercontent.com/u/835177?v=4",
"profile": "https://github.com/davidkna",
"contributions": [
"infra"
]
} }
], ],
"contributorsPerLine": 7, "contributorsPerLine": 7,
+2 -2
View File
@@ -12,12 +12,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: crystallang/crystal:0.35.1-alpine image: crystallang/crystal:1.0.0-alpine
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Install dependencies - name: Install dependencies
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
- name: Build - name: Build
run: make static || make static run: make static || make static
- name: Linter - name: Linter
+1
View File
@@ -13,3 +13,4 @@ public/css/uikit.css
public/img/*.svg public/img/*.svg
public/js/*.min.js public/js/*.min.js
public/css/*.css public/css/*.css
public/webfonts
+4 -4
View File
@@ -1,15 +1,15 @@
FROM crystallang/crystal:0.35.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
WORKDIR / WORKDIR /
COPY --from=builder /Mango/mango . COPY --from=builder /Mango/mango /usr/local/bin/mango
CMD ["./mango"] CMD ["/usr/local/bin/mango"]
+7 -6
View File
@@ -2,13 +2,14 @@ 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.35.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 .
RUN cc 'mango-arm32v7.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib RUN cc 'mango-arm32v7.o' -o '/usr/local/bin/mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
CMD ["/usr/local/bin/mango"]
CMD ["./mango"]
+6 -6
View File
@@ -2,13 +2,13 @@ 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.35.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 .
RUN cc 'mango-arm64v8.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib RUN cc 'mango-arm64v8.o' -o '/usr/local/bin/mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
CMD ["./mango"] CMD ["/usr/local/bin/mango"]
+7 -5
View File
@@ -2,7 +2,7 @@
# Mango # Mango
[![Patreon](https://img.shields.io/badge/support-patreon-brightgreen?link=https://www.patreon.com/hkalexling)](https://www.patreon.com/hkalexling) ![Build](https://github.com/hkalexling/Mango/workflows/Build/badge.svg) [![Gitter](https://badges.gitter.im/mango-cr/mango.svg)](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Patreon](https://img.shields.io/badge/support-patreon-brightgreen?link=https://www.patreon.com/hkalexling)](https://www.patreon.com/hkalexling) ![Build](https://github.com/hkalexling/Mango/workflows/Build/badge.svg) [![Gitter](https://badges.gitter.im/mango-cr/mango.svg)](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Discord](https://img.shields.io/discord/855633663425118228?label=discord)](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.20.0 Mango - Manga Server and Web Reader. Version 0.23.0
Usage: Usage:
@@ -75,6 +74,7 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
```yaml ```yaml
--- ---
host: 0.0.0.0
port: 9000 port: 9000
base_url: / base_url: /
session_secret: mango-session-secret session_secret: mango-session-secret
@@ -86,17 +86,18 @@ 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
disable_login: false disable_login: false
default_username: "" default_username: ""
auth_proxy_header_name: ""
mangadex: mangadex:
base_url: https://mangadex.org base_url: https://mangadex.org
api_url: https://mangadex.org/api api_url: https://api.mangadex.org/v2
download_wait_seconds: 5 download_wait_seconds: 5
download_retries: 4 download_retries: 4
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
@@ -172,6 +173,7 @@ Please check the [development guideline](https://github.com/hkalexling/Mango/wik
<tr> <tr>
<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>
</tr> </tr>
</table> </table>
+20 -16
View File
@@ -4,26 +4,25 @@ const minify = require('gulp-babel-minify');
const minifyCss = require('gulp-minify-css'); const minifyCss = require('gulp-minify-css');
const less = require('gulp-less'); const less = require('gulp-less');
// Copy libraries from node_moduels to public/js gulp.task('copy-img', () => {
gulp.task('copy-js', () => {
return gulp.src([
'node_modules/@fortawesome/fontawesome-free/js/fontawesome.min.js',
'node_modules/@fortawesome/fontawesome-free/js/solid.min.js',
'node_modules/uikit/dist/js/uikit.min.js',
'node_modules/uikit/dist/js/uikit-icons.min.js'
])
.pipe(gulp.dest('public/js'));
});
// Copy UIKit SVG icons to public/img
gulp.task('copy-uikit-icons', () => {
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg') return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
.pipe(gulp.dest('public/img')); .pipe(gulp.dest('public/img'));
}); });
gulp.task('copy-font', () => {
return gulp.src('node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff**')
.pipe(gulp.dest('public/webfonts'));
});
// Copy files from node_modules
gulp.task('node-modules-copy', gulp.parallel('copy-img', 'copy-font'));
// Compile less // Compile less
gulp.task('less', () => { gulp.task('less', () => {
return gulp.src('public/css/*.less') return gulp.src([
'public/css/mango.less',
'public/css/tags.less'
])
.pipe(less()) .pipe(less())
.pipe(gulp.dest('public/css')); .pipe(gulp.dest('public/css'));
}); });
@@ -54,14 +53,19 @@ gulp.task('minify-css', () => {
// Copy static files (includeing images) to dist // Copy static files (includeing images) to dist
gulp.task('copy-files', () => { gulp.task('copy-files', () => {
return gulp.src(['public/img/*', 'public/*.*', 'public/js/*.min.js'], { return gulp.src([
'public/*.*',
'public/img/*',
'public/webfonts/*',
'public/js/*.min.js'
], {
base: 'public' base: 'public'
}) })
.pipe(gulp.dest('dist')); .pipe(gulp.dest('dist'));
}); });
// Set up the public folder for development // Set up the public folder for development
gulp.task('dev', gulp.parallel('copy-js', 'copy-uikit-icons', 'less')); gulp.task('dev', gulp.parallel('node-modules-copy', 'less'));
// Set up the dist folder for deployment // Set up the dist folder for deployment
gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files')); gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));
+20
View File
@@ -0,0 +1,20 @@
class CreateMangaDexAccount < MG::Base
def up : String
<<-SQL
CREATE TABLE md_account (
username TEXT NOT NULL PRIMARY KEY,
token TEXT NOT NULL,
expire INTEGER NOT NULL,
FOREIGN KEY (username) REFERENCES users (username)
ON UPDATE CASCADE
ON DELETE CASCADE
);
SQL
end
def down : String
<<-SQL
DROP TABLE md_account;
SQL
end
end
+16 -1
View File
@@ -1,3 +1,16 @@
// UIKit
@import "./uikit.less";
// FontAwesome
@import "../../node_modules/@fortawesome/fontawesome-free/less/fontawesome.less";
@import "../../node_modules/@fortawesome/fontawesome-free/less/solid.less";
@font-face {
src: url('@{fa-font-path}/fa-solid-900.woff2');
src: url('@{fa-font-path}/fa-solid-900.woff2') format('woff2'),
url('@{fa-font-path}/fa-solid-900.woff') format('woff');
}
// Item cards // Item cards
.item .uk-card { .item .uk-card {
cursor: pointer; cursor: pointer;
@@ -21,9 +34,11 @@
.uk-card-body { .uk-card-body {
padding: 20px; padding: 20px;
.uk-card-title { .uk-card-title {
max-height: 3em;
font-size: 1rem; font-size: 1rem;
} }
.uk-card-title:not(.free-height) {
max-height: 3em;
}
} }
} }
+19
View File
@@ -43,3 +43,22 @@
@internal-list-bullet-image: "../img/list-bullet.svg"; @internal-list-bullet-image: "../img/list-bullet.svg";
@internal-accordion-open-image: "../img/accordion-open.svg"; @internal-accordion-open-image: "../img/accordion-open.svg";
@internal-accordion-close-image: "../img/accordion-close.svg"; @internal-accordion-close-image: "../img/accordion-close.svg";
.hook-card-default() {
.uk-light & {
background: @card-secondary-background;
color: @card-secondary-color;
}
}
.hook-card-default-title() {
.uk-light & {
color: @card-secondary-title-color;
}
}
.hook-card-default-hover() {
.uk-light & {
background-color: @card-secondary-hover-background;
}
}
-4
View File
@@ -117,14 +117,10 @@ const setTheme = (theme) => {
if (theme === 'dark') { if (theme === 'dark') {
$('html').css('background', 'rgb(20, 20, 20)'); $('html').css('background', 'rgb(20, 20, 20)');
$('body').addClass('uk-light'); $('body').addClass('uk-light');
$('.uk-card').addClass('uk-card-secondary');
$('.uk-card').removeClass('uk-card-default');
$('.ui-widget-content').addClass('dark'); $('.ui-widget-content').addClass('dark');
} else { } else {
$('html').css('background', ''); $('html').css('background', '');
$('body').removeClass('uk-light'); $('body').removeClass('uk-light');
$('.uk-card').removeClass('uk-card-secondary');
$('.uk-card').addClass('uk-card-default');
$('.ui-widget-content').removeClass('dark'); $('.ui-widget-content').removeClass('dark');
} }
}; };
-305
View File
@@ -1,305 +0,0 @@
$(() => {
$('#search-input').keypress(event => {
if (event.which === 13) {
search();
}
});
$('.filter-field').each((i, ele) => {
$(ele).change(() => {
buildTable();
});
});
});
const selectAll = () => {
$('tbody > tr').each((i, e) => {
$(e).addClass('ui-selected');
});
};
const unselect = () => {
$('tbody > tr').each((i, e) => {
$(e).removeClass('ui-selected');
});
};
const download = () => {
const selected = $('tbody > tr.ui-selected');
if (selected.length === 0) return;
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
$('#download-btn').attr('hidden', '');
$('#download-spinner').removeAttr('hidden');
const ids = selected.map((i, e) => {
return $(e).find('td').first().text();
}).get();
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0);
console.log(ids);
$.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);
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
window.location.href = base_url + 'admin/downloads';
});
})
.fail((jqXHR, status) => {
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
$('#download-spinner').attr('hidden', '');
$('#download-btn').removeAttr('hidden');
});
});
};
const toggleSpinner = () => {
var attr = $('#spinner').attr('hidden');
if (attr) {
$('#spinner').removeAttr('hidden');
$('#search-btn').attr('hidden', '');
} else {
$('#search-btn').removeAttr('hidden');
$('#spinner').attr('hidden', '');
}
searching = !searching;
};
var searching = false;
var globalChapters;
const search = () => {
if (searching) {
return;
}
$('#manga-details').attr('hidden', '');
$('#filter-form').attr('hidden', '');
$('table').attr('hidden', '');
$('#selection-controls').attr('hidden', '');
$('#filter-notification').attr('hidden', '');
toggleSpinner();
const input = $('input').val();
if (input === "") {
toggleSpinner();
return;
}
var int_id = -1;
try {
const path = new URL(input).pathname;
const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
int_id = parseInt(match[1]);
} catch (e) {
int_id = parseInt(input);
}
if (int_id <= 0 || isNaN(int_id)) {
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex.');
toggleSpinner();
return;
}
$.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;
}
const cover = baseURL + data.cover_url;
$('#cover').attr("src", cover);
$('#title').text("Title: " + data.title);
$('#artist').text("Artist: " + data.artist);
$('#author').text("Author: " + data.author);
$('#manga-details').removeAttr('hidden');
console.log(data.chapters);
globalChapters = data.chapters;
let langs = new Set();
let group_names = new Set();
data.chapters.forEach(chp => {
Object.entries(chp.groups).forEach(([k, v]) => {
group_names.add(k);
});
langs.add(chp.language);
});
const comp = (a, b) => {
var ai;
var bi;
try {
ai = parseFloat(a);
} catch (e) {}
try {
bi = parseFloat(b);
} catch (e) {}
if (typeof ai === 'undefined') return -1;
if (typeof bi === 'undefined') return 1;
if (ai < bi) return 1;
if (ai > bi) return -1;
return 0;
};
langs = [...langs].sort();
group_names = [...group_names].sort();
langs.unshift('All');
group_names.unshift('All');
$('select#lang-select').html(langs.map(e => `<option>${e}</option>`).join(''));
$('select#group-select').html(group_names.map(e => `<option>${e}</option>`).join(''));
$('#filter-form').removeAttr('hidden');
buildTable();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
toggleSpinner();
});
};
const 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) {
alert('danger', `Failed to parse filter input ${str}`);
return [null, null];
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
// e.g., <= 30
num = parseInt(matches[2]);
if (isNaN(num)) {
alert('danger', `Failed to parse filter input ${str}`);
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)) {
alert('danger', `Failed to parse filter input ${str}`);
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) {
alert('danger', `Failed to parse filter input ${str}`);
return [null, null];
}
return [num, n2];
} else {
// empty or space only
return [null, null];
}
};
const getFilters = () => {
const filters = {};
$('.uk-select').each((i, ele) => {
const id = $(ele).attr('id');
const by = id.split('-')[0];
const choice = $(ele).val();
filters[by] = choice;
});
filters.volume = parseRange($('#volume-range').val());
filters.chapter = parseRange($('#chapter-range').val());
return filters;
};
const buildTable = () => {
$('table').attr('hidden', '');
$('#selection-controls').attr('hidden', '');
$('#filter-notification').attr('hidden', '');
console.log('rebuilding table');
const filters = getFilters();
console.log('filters:', filters);
var chapters = globalChapters.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]) => 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);
$('#count-text').text(`${chapters.length} chapters found`);
const chaptersLimit = 1000;
if (chapters.length > chaptersLimit) {
$('#filter-notification').text(`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters in this manga. Please use the filter options above to narrow down your search.`);
$('#filter-notification').removeAttr('hidden');
return;
}
const inner = chapters.map(chp => {
const group_str = Object.entries(chp.groups).map(([k, v]) => {
return `<a href="${baseURL }/group/${v}">${k}</a>`;
}).join(' | ');
return `<tr class="ui-widget-content">
<td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td>
<td>${chp.title}</td>
<td>${chp.language}</td>
<td>${group_str}</td>
<td>${chp.volume}</td>
<td>${chp.chapter}</td>
<td>${moment.unix(chp.time).fromNow()}</td>
</tr>`;
}).join('');
const tbody = `<tbody id="selectable">${inner}</tbody>`;
$('tbody').remove();
$('table').append(tbody);
$('table').removeAttr('hidden');
$("#selectable").selectable({
filter: 'tr'
});
$('#selection-controls').removeAttr('hidden');
};
const unescapeHTML = (str) => {
var elt = document.createElement("span");
elt.innerHTML = str;
return elt.innerText;
};
+1 -3
View File
@@ -126,9 +126,7 @@ const download = () => {
} }
const successCount = parseInt(data.success); const successCount = parseInt(data.success);
const failCount = parseInt(data.fail); const failCount = parseInt(data.fail);
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => { 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>.`);
window.location.href = base_url + 'admin/downloads';
});
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
+60 -17
View File
@@ -6,9 +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
margin: 30,
preloadLookahead: 3,
/** /**
* Initialize the component by fetching the page dimensions * Initialize the component by fetching the page dimensions
@@ -26,7 +30,6 @@ const readerComponent = () => {
url: `${base_url}api/page/${tid}/${eid}/${i+1}`, url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width, width: d.width,
height: d.height, height: d.height,
style: `margin-top: ${data.margin}px; margin-bottom: ${data.margin}px;`
}; };
}); });
@@ -46,6 +49,21 @@ const readerComponent = () => {
const mode = this.mode; const mode = this.mode;
this.updateMode(this.mode, page, nextTick); this.updateMode(this.mode, page, nextTick);
$('#mode-select').val(mode); $('#mode-select').val(mode);
const savedMargin = localStorage.getItem('margin');
if (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}`;
@@ -54,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
*/ */
@@ -105,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;
@@ -221,10 +251,7 @@ const readerComponent = () => {
*/ */
showControl(event) { showControl(event) {
const idx = event.currentTarget.id; const idx = event.currentTarget.id;
const pageCount = this.items.length; this.selectedIndex = idx;
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
$('#progress-label').text(progressText);
$('#page-select').val(idx);
UIkit.modal($('#modal-sections')).show(); UIkit.modal($('#modal-sections')).show();
}, },
/** /**
@@ -263,19 +290,35 @@ const readerComponent = () => {
}); });
}, },
/** /**
* Exits the reader, and optionally sets the reading progress tp 100% * Exits the reader, and sets the reading progress tp 100%
* *
* @param {string} exitUrl - The Exit URL * @param {string} exitUrl - The Exit URL
* @param {boolean} [markCompleted] - Whether we should mark the
* reading progress to 100%
*/ */
exitReader(exitUrl, markCompleted = false) { exitReader(exitUrl) {
if (!markCompleted) {
return this.redirect(exitUrl);
}
this.saveProgress(this.items.length, () => { this.saveProgress(this.items.length, () => {
this.redirect(exitUrl); this.redirect(exitUrl);
}); });
} },
/**
* Handles the `change` event for the entry selector
*/
entryChanged() {
const id = $('#entry-select').val();
this.redirect(`${base_url}reader/${tid}/${id}`);
},
marginChanged() {
localStorage.setItem('margin', this.margin);
this.toPage(this.selectedIndex);
},
preloadLookaheadChanged() {
localStorage.setItem('preloadLookahead', this.preloadLookahead);
},
enableFlipAnimationChanged() {
localStorage.setItem('enableFlipAnimation', this.enableFlipAnimation);
},
}; };
} }
+82
View File
@@ -0,0 +1,82 @@
const component = () => {
return {
available: undefined,
subscriptions: [],
init() {
$.getJSON(`${base_url}api/admin/mangadex/expires`)
.done((data) => {
if (data.error) {
alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
return;
}
this.available = Boolean(data.expires && data.expires > Math.floor(Date.now() / 1000));
if (this.available) this.getSubscriptions();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
},
getSubscriptions() {
$.getJSON(`${base_url}api/admin/mangadex/subscriptions`)
.done(data => {
if (data.error) {
alert('danger', 'Failed to get subscriptions. Error: ' + data.error);
return;
}
this.subscriptions = data.subscriptions;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to get subscriptions. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
},
rm(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({
type: 'DELETE',
url: `${base_url}api/admin/mangadex/subscriptions/${id}`,
contentType: 'application/json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to delete subscription. Error: ${data.error}`);
}
this.getSubscriptions();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to delete subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
check(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({
type: 'POST',
url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`,
contentType: 'application/json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to check subscription. Error: ${data.error}`);
return;
}
alert('success', 'Mango is now checking the subscription for updates. This might take a while, but you can safely leave the page.');
})
.fail((jqXHR, status) => {
alert('danger', `Failed to check subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
formatRange(min, max) {
if (!isNaN(min) && isNaN(max)) return `≥ ${min}`;
if (isNaN(min) && !isNaN(max)) return `≤ ${max}`;
if (isNaN(min) && isNaN(max)) return 'All';
if (min === max) return `= ${min}`;
return `${min} - ${max}`;
}
};
};
+20 -20
View File
@@ -2,77 +2,77 @@ 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.12.1 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
version: 0.7.1 version: 0.8.0
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.12.1 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.5.0 version: 0.8.0
mg: mg:
git: https://github.com/hkalexling/mg.git git: https://github.com/hkalexling/mg.git
version: 0.2.0+git.commit.171c46489d991a8353818e00fc6a3c4e0809ded9 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
+3 -4
View File
@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.20.0 version: 0.23.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.35.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,5 +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
+1 -3
View File
@@ -8,9 +8,7 @@ describe Storage do
end end
it "deletes user" do it "deletes user" do
with_storage do |storage| with_storage &.delete_user "admin"
storage.delete_user "admin"
end
end end
it "creates new user" do it "creates new user" do
+3 -3
View File
@@ -21,7 +21,7 @@ describe "compare_numerically" do
it "sorts like the stack exchange post" do it "sorts like the stack exchange post" do
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2", ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"] "text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
ary.reverse.sort { |a, b| ary.reverse.sort! { |a, b|
compare_numerically a, b compare_numerically a, b
}.should eq ary }.should eq ary
end end
@@ -29,7 +29,7 @@ describe "compare_numerically" do
# https://github.com/hkalexling/Mango/issues/22 # https://github.com/hkalexling/Mango/issues/22
it "handles numbers larger than Int32" do it "handles numbers larger than Int32" do
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"] ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
ary.reverse.sort { |a, b| ary.reverse.sort! { |a, b|
compare_numerically a, b compare_numerically a, b
}.should eq ary }.should eq ary
end end
@@ -56,7 +56,7 @@ describe "chapter_sort" do
it "sorts correctly" do it "sorts correctly" do
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"] ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
sorter = ChapterSorter.new ary sorter = ChapterSorter.new ary
ary.reverse.sort do |a, b| ary.reverse.sort! do |a, b|
sorter.compare a, b sorter.compare a, b
end.should eq ary end.should eq ary
end end
-41
View File
@@ -1,41 +0,0 @@
Arabic,sa
Bengali,bd
Bulgarian,bg
Burmese,mm
Catalan,ct
Chinese (Simp),cn
Chinese (Trad),hk
Czech,cz
Danish,dk
Dutch,nl
English,gb
Filipino,ph
Finnish,fi
French,fr
German,de
Greek,gr
Hebrew,il
Hindi,in
Hungarian,hu
Indonesian,id
Italian,it
Japanese,jp
Korean,kr
Lithuanian,lt
Malay,my
Mongolian,mn
Other,
Persian,ir
Polish,pl
Portuguese (Br),br
Portuguese (Pt),pt
Romanian,ro
Russian,ru
Serbo-Croatian,rs
Spanish (Es),es
Spanish (LATAM),mx
Swedish,se
Thai,th
Turkish,tr
Ukrainian,ua
Vietnames,vn
1 Arabic sa
2 Bengali bd
3 Bulgarian bg
4 Burmese mm
5 Catalan ct
6 Chinese (Simp) cn
7 Chinese (Trad) hk
8 Czech cz
9 Danish dk
10 Dutch nl
11 English gb
12 Filipino ph
13 Finnish fi
14 French fr
15 German de
16 Greek gr
17 Hebrew il
18 Hindi in
19 Hungarian hu
20 Indonesian id
21 Italian it
22 Japanese jp
23 Korean kr
24 Lithuanian lt
25 Malay my
26 Mongolian mn
27 Other
28 Persian ir
29 Polish pl
30 Portuguese (Br) br
31 Portuguese (Pt) pt
32 Romanian ro
33 Russian ru
34 Serbo-Croatian rs
35 Spanish (Es) es
36 Spanish (LATAM) mx
37 Swedish se
38 Thai th
39 Turkish tr
40 Ukrainian ua
41 Vietnames vn
+23 -3
View File
@@ -5,6 +5,7 @@ class Config
@[YAML::Field(ignore: true)] @[YAML::Field(ignore: true)]
property path : String = "" property path : String = ""
property host : String = "0.0.0.0"
property port : Int32 = 9000 property port : Int32 = 9000
property base_url : String = "/" property base_url : String = "/"
property session_secret : String = "mango-session-secret" property session_secret : String = "mango-session-secret"
@@ -19,15 +20,15 @@ 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 page_margin : Int32 = 30
property disable_login = false property disable_login = false
property default_username = "" property default_username = ""
property auth_proxy_header_name = ""
property mangadex = Hash(String, String | Int32).new property mangadex = Hash(String, String | Int32).new
@[YAML::Field(ignore: true)] @[YAML::Field(ignore: true)]
@mangadex_defaults = { @mangadex_defaults = {
"base_url" => "https://mangadex.org", "base_url" => "https://mangadex.org",
"api_url" => "https://mangadex.org/api", "api_url" => "https://api.mangadex.org/v2",
"download_wait_seconds" => 5, "download_wait_seconds" => 5,
"download_retries" => 4, "download_retries" => 4,
"download_queue_db_path" => File.expand_path("~/mango/queue.db", "download_queue_db_path" => File.expand_path("~/mango/queue.db",
@@ -51,9 +52,9 @@ class Config
cfg_path = File.expand_path path, home: true cfg_path = File.expand_path path, home: true
if File.exists? cfg_path if File.exists? cfg_path
config = self.from_yaml File.read cfg_path config = self.from_yaml File.read cfg_path
config.preprocess
config.path = path config.path = path
config.fill_defaults config.fill_defaults
config.preprocess
return config return config
end end
puts "The config file #{cfg_path} does not exist. " \ puts "The config file #{cfg_path} does not exist. " \
@@ -91,5 +92,24 @@ class Config
raise "Login is disabled, but default username is not set. " \ raise "Login is disabled, but default username is not set. " \
"Please set a default username" "Please set a default username"
end end
# `Logger.default` is not available yet
Log.setup :debug
unless mangadex["api_url"] =~ /\/v2/
Log.warn { "It looks like you are using the deprecated MangaDex API " \
"v1 in your config file. Please update it to " \
"https://api.mangadex.org/v2 to suppress this warning." }
mangadex["api_url"] = "https://api.mangadex.org/v2"
end
if mangadex["api_url"] =~ /\/api\/v2/
Log.warn { "It looks like you are using the outdated MangaDex API " \
"url (mangadex.org/api/v2) in your config file. Please " \
"update it to https://api.mangadex.org/v2 to suppress this " \
"warning." }
mangadex["api_url"] = "https://api.mangadex.org/v2"
end
mangadex["api_url"] = mangadex["api_url"].to_s.rstrip "/"
mangadex["base_url"] = mangadex["base_url"].to_s.rstrip "/"
end end
end end
+38 -39
View File
@@ -15,7 +15,11 @@ class AuthHandler < Kemal::Handler
env.response.status_code = 401 env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
env.response.print AUTH_MESSAGE env.response.print AUTH_MESSAGE
call_next env end
def require_auth(env)
env.session.string "callback", env.request.path
redirect env, "/login"
end end
def validate_token(env) def validate_token(env)
@@ -49,55 +53,50 @@ class AuthHandler < Kemal::Handler
Storage.default.verify_user username, password Storage.default.verify_user username, password
end end
def handle_opds_auth(env) def call(env)
if validate_token(env) || validate_auth_header(env) # Skip all authentication if requesting /login, /logout, or a static file
call_next env
else
env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
env.response.print AUTH_MESSAGE
end
end
def handle_auth(env)
if request_path_startswith(env, ["/login", "/logout"]) || if request_path_startswith(env, ["/login", "/logout"]) ||
requesting_static_file env requesting_static_file env
return call_next(env) return call_next(env)
end end
unless validate_token(env) || Config.current.disable_login # Check user is logged in
env.session.string "callback", env.request.path if validate_token env
return redirect env, "/login" # Skip if the request has a valid token
elsif Config.current.disable_login
# Check default username if login is disabled
unless Storage.default.username_exists Config.current.default_username
Logger.warn "Default username #{Config.current.default_username} " \
"does not exist"
return require_auth env
end
elsif !Config.current.auth_proxy_header_name.empty?
# Check auth proxy if present
username = env.request.headers[Config.current.auth_proxy_header_name]?
unless username && Storage.default.username_exists username
Logger.warn "Header #{Config.current.auth_proxy_header_name} unset " \
"or is not a valid username"
return require_auth env
end
elsif request_path_startswith env, ["/opds"]
# Check auth header if requesting an opds page
unless validate_auth_header env
return require_basic_auth env
end
else
return require_auth env
end end
if request_path_startswith env, ["/admin", "/api/admin", "/download"] # Check admin access when requesting an admin page
# The token (if exists) takes precedence over the default user option. if request_path_startswith env, %w(/admin /api/admin /download)
# this is why we check the default username first before checking the unless is_admin? env
# token.
should_reject = true
if Config.current.disable_login &&
Storage.default.username_is_admin Config.current.default_username
should_reject = false
end
if env.session.string? "token"
should_reject = !validate_token_admin(env)
end
if should_reject
env.response.status_code = 403 env.response.status_code = 403
send_error_page "HTTP 403: You are not authorized to visit " \ return send_error_page "HTTP 403: You are not authorized to visit " \
"#{env.request.path}" "#{env.request.path}"
return
end end
end end
# Let the request go through if it passes the above checks
call_next env call_next env
end end
def call(env)
if request_path_startswith env, ["/opds"]
handle_opds_auth env
else
handle_auth env
end
end
end end
+17 -4
View File
@@ -46,6 +46,18 @@ class Entry
file.close file.close
end end
def to_slim_json : String
JSON.build do |json|
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 "pages" { json.number @pages }
end
end
end
def to_json(json : JSON::Builder) def to_json(json : JSON::Builder)
json.object do json.object do
{% for str in ["zip_path", "title", "size", "id"] %} {% for str in ["zip_path", "title", "size", "id"] %}
@@ -86,7 +98,7 @@ class Entry
SUPPORTED_IMG_TYPES.includes? \ SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename MIME.from_filename? e.filename
} }
.sort { |a, b| .sort! { |a, b|
compare_numerically a.filename, b.filename compare_numerically a.filename, b.filename
} }
yield file, entries yield file, entries
@@ -134,10 +146,11 @@ class Entry
entries[idx + 1] entries[idx + 1]
end end
def previous_entry def previous_entry(username)
idx = @book.entries.index self entries = @book.sorted_entries username
idx = entries.index self
return nil if idx.nil? || idx == 0 return nil if idx.nil? || idx == 0
@book.entries[idx - 1] entries[idx - 1]
end end
def date_added def date_added
+26 -11
View File
@@ -63,7 +63,22 @@ class Library
end end
def deep_titles def deep_titles
titles + titles.map { |t| t.deep_titles }.flatten titles + titles.flat_map &.deep_titles
end
def to_slim_json : String
JSON.build do |json|
json.object do
json.field "dir", @dir
json.field "titles" do
json.array do
self.titles.each do |title|
json.raw title.to_slim_json
end
end
end
end
end
end end
def to_json(json : JSON::Builder) def to_json(json : JSON::Builder)
@@ -98,7 +113,7 @@ class Library
.select { |path| File.directory? path } .select { |path| File.directory? path }
.map { |path| Title.new path, "" } .map { |path| Title.new path, "" }
.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 } .tap { |_| @title_ids.clear }
.each do |title| .each do |title|
@title_hash[title.id] = title @title_hash[title.id] = title
@@ -114,14 +129,14 @@ class Library
def get_continue_reading_entries(username) def get_continue_reading_entries(username)
cr_entries = deep_titles cr_entries = deep_titles
.map { |t| t.get_last_read_entry username } .map(&.get_last_read_entry username)
# Select elements with type `Entry` from the array and ignore all `Nil`s # Select elements with type `Entry` from the array and ignore all `Nil`s
.select(Entry)[0...ENTRIES_IN_HOME_SECTIONS] .select(Entry)[0...ENTRIES_IN_HOME_SECTIONS]
.map { |e| .map { |e|
# Get the last read time of the entry. If it hasn't been started, get # Get the last read time of the entry. If it hasn't been started, get
# the last read time of the previous entry # the last read time of the previous entry
last_read = e.load_last_read username last_read = e.load_last_read username
pe = e.previous_entry pe = e.previous_entry username
if last_read.nil? && pe if last_read.nil? && pe
last_read = pe.load_last_read username last_read = pe.load_last_read username
end end
@@ -150,14 +165,14 @@ class Library
recently_added = [] of RA recently_added = [] of RA
last_date_added = nil last_date_added = nil
titles.map { |t| t.deep_entries_with_date_added }.flatten titles.flat_map(&.deep_entries_with_date_added)
.select { |e| e[:date_added] > 1.month.ago } .select(&.[:date_added].> 1.month.ago)
.sort { |a, b| b[:date_added] <=> a[:date_added] } .sort! { |a, b| b[:date_added] <=> a[:date_added] }
.each do |e| .each do |e|
break if recently_added.size > 12 break if recently_added.size > 12
last = recently_added.last? last = recently_added.last?
if last && e[:entry].book.id == last[:entry].book.id && if last && e[:entry].book.id == last[:entry].book.id &&
(e[:date_added] - last_date_added.not_nil!).duration < 1.day (e[:date_added] - last_date_added.not_nil!).abs < 1.day
# A NamedTuple is immutable, so we have to cast it to a Hash first # A NamedTuple is immutable, so we have to cast it to a Hash first
last_hash = last.to_h last_hash = last.to_h
count = last_hash[:grouped_count].as(Int32) count = last_hash[:grouped_count].as(Int32)
@@ -188,9 +203,9 @@ class Library
# If we use `deep_titles`, the start reading section might include `Vol. 2` # If we use `deep_titles`, the start reading section might include `Vol. 2`
# when the user hasn't started `Vol. 1` yet # when the user hasn't started `Vol. 1` yet
titles titles
.select { |t| t.load_percentage(username) == 0 } .select(&.load_percentage(username).== 0)
.sample(ENTRIES_IN_HOME_SECTIONS) .sample(ENTRIES_IN_HOME_SECTIONS)
.shuffle .shuffle!
end end
def thumbnail_generation_progress def thumbnail_generation_progress
@@ -205,7 +220,7 @@ class Library
end end
Logger.info "Starting thumbnail generation" Logger.info "Starting thumbnail generation"
entries = deep_titles.map(&.deep_entries).flatten.reject &.err_msg entries = deep_titles.flat_map(&.deep_entries).reject &.err_msg
@entries_count = entries.size @entries_count = entries.size
@thumbnails_count = 0 @thumbnails_count = 0
+50 -21
View File
@@ -44,19 +44,54 @@ class Title
mtimes = [@mtime] mtimes = [@mtime]
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime } mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
mtimes += @entries.map { |e| e.mtime } mtimes += @entries.map &.mtime
@mtime = mtimes.max @mtime = mtimes.max
@title_ids.sort! do |a, b| @title_ids.sort! do |a, b|
compare_numerically Library.default.title_hash[a].title, compare_numerically Library.default.title_hash[a].title,
Library.default.title_hash[b].title Library.default.title_hash[b].title
end end
sorter = ChapterSorter.new @entries.map { |e| e.title } sorter = ChapterSorter.new @entries.map &.title
@entries.sort! do |a, b| @entries.sort! do |a, b|
sorter.compare a.title, b.title sorter.compare a.title, b.title
end end
end end
def to_slim_json : String
JSON.build do |json|
json.object do
{% for str in ["dir", "title", "id"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "signature" { json.number @signature }
json.field "titles" do
json.array do
self.titles.each do |title|
json.raw title.to_slim_json
end
end
end
json.field "entries" do
json.array do
@entries.each do |entry|
json.raw entry.to_slim_json
end
end
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
end
def to_json(json : JSON::Builder) def to_json(json : JSON::Builder)
json.object do json.object do
{% for str in ["dir", "title", "id"] %} {% for str in ["dir", "title", "id"] %}
@@ -92,12 +127,12 @@ class Title
# Get all entries, including entries in nested titles # Get all entries, including entries in nested titles
def deep_entries def deep_entries
return @entries if title_ids.empty? return @entries if title_ids.empty?
@entries + titles.map { |t| t.deep_entries }.flatten @entries + titles.flat_map &.deep_entries
end end
def deep_titles def deep_titles
return [] of Title if titles.empty? return [] of Title if titles.empty?
titles + titles.map { |t| t.deep_titles }.flatten titles + titles.flat_map &.deep_titles
end end
def parents def parents
@@ -138,7 +173,7 @@ class Title
end end
def get_entry(eid) def get_entry(eid)
@entries.find { |e| e.id == eid } @entries.find &.id.== eid
end end
def display_name def display_name
@@ -217,29 +252,23 @@ class Title
@entries.each do |e| @entries.each do |e|
e.save_progress username, e.pages e.save_progress username, e.pages
end end
titles.each do |t| titles.each &.read_all username
t.read_all username
end
end end
# Set the reading progress of all entries and nested libraries to 0% # Set the reading progress of all entries and nested libraries to 0%
def unread_all(username) def unread_all(username)
@entries.each do |e| @entries.each &.save_progress(username, 0)
e.save_progress username, 0 titles.each &.unread_all username
end
titles.each do |t|
t.unread_all username
end
end end
def deep_read_page_count(username) : Int32 def deep_read_page_count(username) : Int32
load_progress_for_all_entries(username).sum + load_progress_for_all_entries(username).sum +
titles.map { |t| t.deep_read_page_count username }.flatten.sum titles.flat_map(&.deep_read_page_count username).sum
end end
def deep_total_page_count : Int32 def deep_total_page_count : Int32
entries.map { |e| e.pages }.sum + entries.sum(&.pages) +
titles.map { |t| t.deep_total_page_count }.flatten.sum titles.flat_map(&.deep_total_page_count).sum
end end
def load_percentage(username) def load_percentage(username)
@@ -311,13 +340,13 @@ class Title
ary = @entries.zip(percentage_ary) ary = @entries.zip(percentage_ary)
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \ .sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
compare_numerically a_tp[0].title, b_tp[0].title } compare_numerically a_tp[0].title, b_tp[0].title }
.map { |tp| tp[0] } .map &.[0]
else else
unless opt.method.auto? unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \ Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead" "Auto instead"
end end
sorter = ChapterSorter.new @entries.map { |e| e.title } sorter = ChapterSorter.new @entries.map &.title
ary = @entries.sort do |a, b| ary = @entries.sort do |a, b|
sorter.compare(a.title, b.title).or \ sorter.compare(a.title, b.title).or \
compare_numerically a.title, b.title compare_numerically a.title, b.title
@@ -383,13 +412,13 @@ class Title
{entry: e, date_added: da_ary[i]} {entry: e, date_added: da_ary[i]}
end end
return zip if title_ids.empty? return zip if title_ids.empty?
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten zip + titles.flat_map &.deep_entries_with_date_added
end end
def bulk_progress(action, ids : Array(String), username) def bulk_progress(action, ids : Array(String), username)
selected_entries = ids selected_entries = ids
.map { |id| .map { |id|
@entries.find { |e| e.id == id } @entries.find &.id.==(id)
} }
.select(Entry) .select(Entry)
+5 -1
View File
@@ -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
-217
View File
@@ -1,217 +0,0 @@
require "json"
require "csv"
require "../rename"
macro string_properties(names)
{% for name in names %}
property {{name.id}} = ""
{% end %}
end
macro parse_strings_from_json(names)
{% for name in names %}
@{{name.id}} = obj[{{name}}].as_s
{% end %}
end
macro properties_to_hash(names)
{
{% for name in names %}
"{{name.id}}" => @{{name.id}}.to_s,
{% end %}
}
end
module MangaDex
class Chapter
string_properties ["lang_code", "title", "volume", "chapter"]
property manga : Manga
property time = Time.local
property id : String
property full_title = ""
property language = ""
property pages = [] of {String, String} # filename, url
property groups = [] of {Int32, String} # group_id, group_name
def initialize(@id, json_obj : JSON::Any, @manga,
lang : Hash(String, String))
self.parse_json json_obj, lang
end
def to_info_json
JSON.build do |json|
json.object do
{% for name in ["id", "title", "volume", "chapter",
"language", "full_title"] %}
json.field {{name}}, @{{name.id}}
{% end %}
json.field "time", @time.to_unix.to_s
json.field "manga_title", @manga.title
json.field "manga_id", @manga.id
json.field "groups" do
json.object do
@groups.each do |gid, gname|
json.field gname, gid
end
end
end
end
end
end
def parse_json(obj, lang)
parse_strings_from_json ["lang_code", "title", "volume",
"chapter"]
language = lang[@lang_code]?
@language = language if language
@time = Time.unix obj["timestamp"].as_i
suffixes = ["", "_2", "_3"]
suffixes.each do |s|
gid = obj["group_id#{s}"].as_i
next if gid == 0
gname = obj["group_name#{s}"].as_s
@groups << {gid, gname}
end
rename_rule = Rename::Rule.new \
Config.current.mangadex["chapter_rename_rule"].to_s
@full_title = rename rename_rule
rescue e
raise "failed to parse json: #{e}"
end
def rename(rule : Rename::Rule)
hash = properties_to_hash ["id", "title", "volume", "chapter",
"lang_code", "language", "pages"]
hash["groups"] = @groups.map { |g| g[1] }.join ","
rule.render hash
end
end
class Manga
string_properties ["cover_url", "description", "title", "author", "artist"]
property chapters = [] of Chapter
property id : String
def initialize(@id, json_obj : JSON::Any)
self.parse_json json_obj
end
def to_info_json(with_chapters = true)
JSON.build do |json|
json.object do
{% for name in ["id", "title", "description", "author", "artist",
"cover_url"] %}
json.field {{name}}, @{{name.id}}
{% end %}
if with_chapters
json.field "chapters" do
json.array do
@chapters.each do |c|
json.raw c.to_info_json
end
end
end
end
end
end
end
def parse_json(obj)
parse_strings_from_json ["cover_url", "description", "title", "author",
"artist"]
rescue e
raise "failed to parse json: #{e}"
end
def rename(rule : Rename::Rule)
rule.render properties_to_hash ["id", "title", "author", "artist"]
end
end
class API
use_default
def initialize
@base_url = Config.current.mangadex["api_url"].to_s ||
"https://mangadex.org/api/"
@lang = {} of String => String
CSV.each_row {{read_file "src/assets/lang_codes.csv"}} do |row|
@lang[row[1]] = row[0]
end
end
def get(url)
headers = HTTP::Headers{
"User-agent" => "Mangadex.cr",
}
res = HTTP::Client.get url, headers
raise "Failed to get #{url}. [#{res.status_code}] " \
"#{res.status_message}" if !res.success?
JSON.parse res.body
end
def get_manga(id)
obj = self.get File.join @base_url, "manga/#{id}"
if obj["status"]? != "OK"
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
end
begin
manga = Manga.new id, obj["manga"]
obj["chapter"].as_h.map do |k, v|
chapter = Chapter.new k, v, manga, @lang
manga.chapters << chapter
end
manga
rescue
raise "Failed to parse JSON"
end
end
def get_chapter(chapter : Chapter)
obj = self.get File.join @base_url, "chapter/#{chapter.id}"
if obj["status"]? == "external"
raise "This chapter is hosted on an external site " \
"#{obj["external"]?}, and Mango does not support " \
"external chapters."
end
if obj["status"]? != "OK"
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
end
begin
server = obj["server"].as_s
hash = obj["hash"].as_s
chapter.pages = obj["page_array"].as_a.map do |fn|
{
fn.as_s,
"#{server}#{hash}/#{fn.as_s}",
}
end
rescue
raise "Failed to parse JSON"
end
end
def get_chapter(id : String)
obj = self.get File.join @base_url, "chapter/#{id}"
if obj["status"]? == "external"
raise "This chapter is hosted on an external site " \
"#{obj["external"]?}, and Mango does not support " \
"external chapters."
end
if obj["status"]? != "OK"
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
end
manga_id = ""
begin
manga_id = obj["manga_id"].as_i.to_s
rescue
raise "Failed to parse JSON"
end
manga = self.get_manga manga_id
chapter = manga.chapters.find { |c| c.id == id }.not_nil!
self.get_chapter chapter
chapter
end
end
end
-167
View File
@@ -1,167 +0,0 @@
require "./api"
require "compress/zip"
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
@api = API.default
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 = @api.get_chapter(job.id)
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 chapter.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(chapter.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 chapter.pages.size
spawn do
chapter.pages.each_with_index do |tuple, i|
fn, url = tuple
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
chapter.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 -3
View File
@@ -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.20.0" MANGO_VERSION = "0.23.0"
# From http://www.network-science.de/ascii/ # From http://www.network-science.de/ascii/
BANNER = %{ BANNER = %{
@@ -59,7 +58,6 @@ class CLI < Clim
Storage.default Storage.default
Queue.default Queue.default
Library.default Library.default
MangaDex::Downloader.default
Plugin::Downloader.default Plugin::Downloader.default
spawn do spawn do
+1 -1
View File
@@ -117,7 +117,7 @@ class Plugin
def initialize(id : String) def initialize(id : String)
Plugin.build_info_ary Plugin.build_info_ary
@info = @@info_ary.find { |i| i.id == id } @info = @@info_ary.find &.id.== id
if @info.nil? if @info.nil?
raise Error.new "Plugin with ID #{id} not found" raise Error.new "Plugin with ID #{id} not found"
end end
+2 -2
View File
@@ -303,12 +303,12 @@ class Queue
end end
def pause def pause
@downloaders.each { |d| d.stopped = true } @downloaders.each &.stopped=(true)
@paused = true @paused = true
end end
def resume def resume
@downloaders.each { |d| d.stopped = false } @downloaders.each &.stopped=(false)
@paused = false @paused = false
end end
+5 -5
View File
@@ -35,15 +35,15 @@ module Rename
class Group < Base(Pattern | String) class Group < Base(Pattern | String)
def render(hash : VHash) def render(hash : VHash)
return "" if @ary.select(&.is_a? Pattern) return "" if @ary.select(Pattern)
.any? &.as(Pattern).render(hash).empty? .any? &.as(Pattern).render(hash).empty?
@ary.map do |e| @ary.join do |e|
if e.is_a? Pattern if e.is_a? Pattern
e.render hash e.render hash
else else
e e
end end
end.join end
end end
end end
@@ -129,13 +129,13 @@ module Rename
end end
def render(hash : VHash) def render(hash : VHash)
str = @ary.map do |e| str = @ary.join do |e|
if e.is_a? String if e.is_a? String
e e
else else
e.render hash e.render hash
end end
end.join.strip end.strip
post_process str post_process str
end end
+217 -254
View File
@@ -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?
@@ -10,7 +10,7 @@ struct APIRouter
macro s(fields) macro s(fields)
{ {
{% for field in fields %} {% for field in fields %}
{{field}} => "string", {{field}} => String,
{% end %} {% end %}
} }
end end
@@ -33,165 +33,43 @@ struct APIRouter
MD MD
Koa.cookie_auth "cookie", "mango-sessid-#{Config.current.port}" Koa.cookie_auth "cookie", "mango-sessid-#{Config.current.port}"
Koa.global_tag "admin", desc: <<-MD Koa.define_tag "admin", desc: <<-MD
These are the admin endpoints only accessible for users with admin access. A non-admin user will get HTTP 403 when calling the endpoints. These are the admin endpoints only accessible for users with admin access. A non-admin user will get HTTP 403 when calling the endpoints.
MD MD
Koa.binary "binary", desc: "A binary file" Koa.schema "entry", {
Koa.array "entryAry", "$entry", desc: "An array of entries" "pages" => Int32,
Koa.array "titleAry", "$title", desc: "An array of titles" "mtime" => Int64,
Koa.array "strAry", "string", desc: "An array of strings" }.merge(s %w(zip_path title size id title_id display_name cover_url)),
desc: "An entry in a book"
entry_schema = { Koa.schema "title", {
"pages" => "integer", "mtime" => Int64,
"mtime" => "integer", "entries" => ["entry"],
}.merge s %w(zip_path title size id title_id display_name cover_url) "titles" => ["title"],
Koa.object "entry", entry_schema, desc: "An entry in a book" "parents" => [String],
}.merge(s %w(dir title id display_name cover_url)),
title_schema = {
"mtime" => "integer",
"entries" => "$entryAry",
"titles" => "$titleAry",
"parents" => "$strAry",
}.merge s %w(dir title id display_name cover_url)
Koa.object "title", title_schema,
desc: "A manga title (a collection of entries and sub-titles)" desc: "A manga title (a collection of entries and sub-titles)"
Koa.object "library", { Koa.schema "result", {
"dir" => "string", "success" => Bool,
"titles" => "$titleAry", "error" => String?,
}, desc: "A library containing a list of top-level titles"
Koa.object "scanResult", {
"milliseconds" => "integer",
"titles" => "integer",
}
Koa.object "progressResult", {
"progress" => "number",
}
Koa.object "result", {
"success" => "boolean",
"error" => "string?",
}
mc_schema = {
"groups" => "object",
}.merge s %w(id title volume chapter language full_title time manga_title manga_id)
Koa.object "mangadexChapter", mc_schema, desc: "A MangaDex chapter"
Koa.array "chapterAry", "$mangadexChapter"
mm_schema = {
"chapers" => "$chapterAry",
}.merge s %w(id title description author artist cover_url)
Koa.object "mangadexManga", mm_schema, desc: "A MangaDex manga"
Koa.object "chaptersObj", {
"chapters" => "$chapterAry",
}
Koa.object "successFailCount", {
"success" => "integer",
"fail" => "integer",
}
job_schema = {
"pages" => "integer",
"success_count" => "integer",
"fail_count" => "integer",
"time" => "integer",
}.merge s %w(id manga_id title manga_title status_message status)
Koa.object "job", job_schema, desc: "A download job in the queue"
Koa.array "jobAry", "$job"
Koa.object "jobs", {
"success" => "boolean",
"paused" => "boolean",
"jobs" => "$jobAry",
}
Koa.object "binaryUpload", {
"file" => "$binary",
}
Koa.object "pluginListBody", {
"plugin" => "string",
"query" => "string",
}
Koa.object "pluginChapter", {
"id" => "string",
"title" => "string",
}
Koa.array "pluginChapterAry", "$pluginChapter"
Koa.object "pluginList", {
"success" => "boolean",
"chapters" => "$pluginChapterAry?",
"title" => "string?",
"error" => "string?",
}
Koa.object "pluginDownload", {
"plugin" => "string",
"title" => "string",
"chapters" => "$pluginChapterAry",
}
Koa.object "dimension", {
"width" => "integer",
"height" => "integer",
}
Koa.array "dimensionAry", "$dimension"
Koa.object "dimensionResult", {
"success" => "boolean",
"dimensions" => "$dimensionAry?",
"margin" => "number",
"error" => "string?",
}
Koa.object "ids", {
"ids" => "$strAry",
}
Koa.object "tagsResult", {
"success" => "boolean",
"tags" => "$strAry?",
"error" => "string?",
}
Koa.object "missing", {
"path" => "string",
"id" => "string",
"signature" => "string",
}
Koa.array "missingAry", "$missing"
Koa.object "missingResult", {
"success" => "boolean",
"error" => "string?",
"entries" => "$missingAry?",
"titles" => "$missingAry?",
} }
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"
Koa.path "eid", desc: "Entry ID" Koa.path "eid", desc: "Entry ID"
Koa.path "page", type: "integer", 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, ref: "$binary", 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"
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?
@@ -201,7 +79,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
@@ -212,12 +98,15 @@ struct APIRouter
Koa.describe "Returns the cover image of a manga entry" Koa.describe "Returns the cover image of a manga entry"
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, ref: "$binary", 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"
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?
@@ -228,7 +117,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
@@ -236,17 +132,25 @@ struct APIRouter
end end
end end
Koa.describe "Returns the book with title `tid`" 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
MD
Koa.path "tid", desc: "Title ID" Koa.path "tid", desc: "Title ID"
Koa.response 200, ref: "$title" Koa.query "slim"
Koa.response 200, schema: "title"
Koa.response 404, "Title not found" Koa.response 404, "Title not found"
Koa.tag "library"
get "/api/book/:tid" do |env| get "/api/book/:tid" do |env|
begin begin
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?
send_json env, title.to_json if env.params.query["slim"]?
send_json env, title.to_slim_json
else
send_json env, title.to_json
end
rescue e rescue e
Logger.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404
@@ -254,15 +158,29 @@ struct APIRouter
end end
end end
Koa.describe "Returns the entire library with all titles and entries" Koa.describe "Returns the entire library with all titles and entries", <<-MD
Koa.response 200, ref: "$library" Supply the `tid` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
MD
Koa.query "slim"
Koa.response 200, schema: {
"dir" => String,
"titles" => ["title"],
}
Koa.tag "library"
get "/api/library" do |env| get "/api/library" do |env|
send_json env, Library.default.to_json if env.params.query["slim"]?
send_json env, Library.default.to_slim_json
else
send_json env, Library.default.to_json
end
end end
Koa.describe "Triggers a library scan" Koa.describe "Triggers a library scan"
Koa.tag "admin" Koa.tags ["admin", "library"]
Koa.response 200, ref: "$scanResult" Koa.response 200, schema: {
"milliseconds" => Float64,
"titles" => Int32,
}
post "/api/admin/scan" do |env| post "/api/admin/scan" do |env|
start = Time.utc start = Time.utc
Library.default.scan Library.default.scan
@@ -274,8 +192,10 @@ struct APIRouter
end end
Koa.describe "Returns the thumbnail generation progress between 0 and 1" Koa.describe "Returns the thumbnail generation progress between 0 and 1"
Koa.tag "admin" Koa.tags ["admin", "library"]
Koa.response 200, ref: "$progressResult" Koa.response 200, schema: {
"progress" => Float64,
}
get "/api/admin/thumbnail_progress" do |env| get "/api/admin/thumbnail_progress" do |env|
send_json env, { send_json env, {
"progress" => Library.default.thumbnail_generation_progress, "progress" => Library.default.thumbnail_generation_progress,
@@ -283,7 +203,7 @@ struct APIRouter
end end
Koa.describe "Triggers a thumbnail generation" Koa.describe "Triggers a thumbnail generation"
Koa.tag "admin" Koa.tags ["admin", "library"]
post "/api/admin/generate_thumbnails" do |env| post "/api/admin/generate_thumbnails" do |env|
spawn do spawn do
Library.default.generate_thumbnails Library.default.generate_thumbnails
@@ -291,8 +211,8 @@ struct APIRouter
end end
Koa.describe "Deletes a user with `username`" Koa.describe "Deletes a user with `username`"
Koa.tag "admin" Koa.tags ["admin", "users"]
Koa.response 200, ref: "$result" Koa.response 200, schema: "result"
delete "/api/admin/user/delete/:username" do |env| delete "/api/admin/user/delete/:username" do |env|
begin begin
username = env.params.url["username"] username = env.params.url["username"]
@@ -319,7 +239,8 @@ struct APIRouter
Koa.path "tid", desc: "Title ID" Koa.path "tid", desc: "Title ID"
Koa.query "eid", desc: "Entry ID", required: false Koa.query "eid", desc: "Entry ID", required: false
Koa.path "page", desc: "The new page number indicating the progress" Koa.path "page", desc: "The new page number indicating the progress"
Koa.response 200, ref: "$result" Koa.response 200, schema: "result"
Koa.tag "progress"
put "/api/progress/:tid/:page" do |env| put "/api/progress/:tid/:page" do |env|
begin begin
username = get_username env username = get_username env
@@ -350,8 +271,11 @@ struct APIRouter
Koa.describe "Updates the reading progress of multiple entries in a title" Koa.describe "Updates the reading progress of multiple entries in a title"
Koa.path "action", desc: "The action to perform. Can be either `read` or `unread`" Koa.path "action", desc: "The action to perform. Can be either `read` or `unread`"
Koa.path "tid", desc: "Title ID" Koa.path "tid", desc: "Title ID"
Koa.body ref: "$ids", desc: "An array of entry IDs" Koa.body schema: {
Koa.response 200, ref: "$result" "ids" => [String],
}, desc: "An array of entry IDs"
Koa.response 200, schema: "result"
Koa.tag "progress"
put "/api/bulk_progress/:action/:tid" do |env| put "/api/bulk_progress/:action/:tid" do |env|
begin begin
username = get_username env username = get_username env
@@ -377,11 +301,11 @@ struct APIRouter
Koa.describe "Sets the display name of a title or an entry", <<-MD Koa.describe "Sets the display name of a title or an entry", <<-MD
When `eid` is provided, apply the display name to the entry. Otherwise, apply the display name to the title identified by `tid`. When `eid` is provided, apply the display name to the entry. Otherwise, apply the display name to the title identified by `tid`.
MD MD
Koa.tag "admin" Koa.tags ["admin", "library"]
Koa.path "tid", desc: "Title ID" Koa.path "tid", desc: "Title ID"
Koa.query "eid", desc: "Entry ID", required: false Koa.query "eid", desc: "Entry ID", required: false
Koa.path "name", desc: "The new display name" Koa.path "name", desc: "The new display name"
Koa.response 200, ref: "$result" Koa.response 200, schema: "result"
put "/api/admin/display_name/:tid/:name" do |env| put "/api/admin/display_name/:tid/:name" do |env|
begin begin
title = (Library.default.get_title env.params.url["tid"]) title = (Library.default.get_title env.params.url["tid"])
@@ -405,60 +329,12 @@ 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.tag "admin"
Koa.path "id", desc: "A MangaDex manga ID"
Koa.response 200, ref: "$mangadexManga"
get "/api/admin/mangadex/manga/:id" do |env|
begin
id = env.params.url["id"]
api = MangaDex::API.default
manga = api.get_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.tag "admin"
Koa.body ref: "$chaptersObj"
Koa.response 200, ref: "$successFailCount"
post "/api/admin/mangadex/download" do |env|
begin
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
jobs = chapters.map { |chapter|
Queue::Job.new(
chapter["id"].as_s,
chapter["manga_id"].as_s,
chapter["full_title"].as_s,
chapter["manga_title"].as_s,
Queue::JobStatus::Pending,
Time.unix chapter["time"].as_s.to_i
)
}
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
loop do loop do
socket.send({ socket.send({
"jobs" => Queue.default.get_all, "jobs" => Queue.default.get_all.reverse,
"paused" => Queue.default.paused?, "paused" => Queue.default.paused?,
}.to_json) }.to_json)
sleep interval.seconds sleep interval.seconds
@@ -468,17 +344,27 @@ struct APIRouter
Koa.describe "Returns the current download queue", <<-MD Koa.describe "Returns the current download queue", <<-MD
On error, returns a JSON that contains the error message in the `error` field. On error, returns a JSON that contains the error message in the `error` field.
MD MD
Koa.tag "admin" Koa.tags ["admin", "downloader"]
Koa.response 200, ref: "$jobs" Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"paused" => Bool?,
"jobs?" => [{
"pages" => Int32,
"success_count" => Int32,
"fail_count" => Int32,
"time" => Int64,
}.merge(s %w(id manga_id title manga_title status_message status))],
}
get "/api/admin/mangadex/queue" do |env| get "/api/admin/mangadex/queue" do |env|
begin begin
jobs = Queue.default.get_all
send_json env, { send_json env, {
"jobs" => jobs, "jobs" => Queue.default.get_all.reverse,
"paused" => Queue.default.paused?, "paused" => Queue.default.paused?,
"success" => true, "success" => true,
}.to_json }.to_json
rescue e rescue e
Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -495,10 +381,10 @@ struct APIRouter
When `action` is set to `retry`, the behavior depends on `id`. If `id` is provided, restarts the job identified by the ID. Otherwise, retries all jobs in the `Error` or `MissingPages` status in the queue. When `action` is set to `retry`, the behavior depends on `id`. If `id` is provided, restarts the job identified by the ID. Otherwise, retries all jobs in the `Error` or `MissingPages` status in the queue.
MD MD
Koa.tag "admin" Koa.tags ["admin", "downloader"]
Koa.path "action", desc: "The action to perform. It should be one of the followins: `delete`, `retry`, `pause` and `resume`." Koa.path "action", desc: "The action to perform. It should be one of the followins: `delete`, `retry`, `pause` and `resume`."
Koa.query "id", required: false, desc: "A job ID" Koa.query "id", required: false, desc: "A job ID"
Koa.response 200, ref: "$result" Koa.response 200, schema: "result"
post "/api/admin/mangadex/queue/:action" do |env| post "/api/admin/mangadex/queue/:action" do |env|
begin begin
action = env.params.url["action"] action = env.params.url["action"]
@@ -526,6 +412,7 @@ struct APIRouter
send_json env, {"success" => true}.to_json send_json env, {"success" => true}.to_json
rescue e rescue e
Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -547,8 +434,10 @@ struct APIRouter
When `eid` is omitted, the new cover image will be applied to the title. Otherwise, applies the image to the specified entry. When `eid` is omitted, the new cover image will be applied to the title. Otherwise, applies the image to the specified entry.
MD MD
Koa.tag "admin" Koa.tag "admin"
Koa.body type: "multipart/form-data", ref: "$binaryUpload" Koa.body media_type: "multipart/form-data", schema: {
Koa.response 200, ref: "$result" "file" => Bytes,
}
Koa.response 200, schema: "result"
post "/api/admin/upload/:target" do |env| post "/api/admin/upload/:target" do |env|
begin begin
target = env.params.url["target"] target = env.params.url["target"]
@@ -596,6 +485,7 @@ struct APIRouter
raise "No part with name `file` found" raise "No part with name `file` found"
rescue e rescue e
Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -604,9 +494,18 @@ struct APIRouter
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.tag "admin" Koa.tags ["admin", "downloader"]
Koa.body ref: "$pluginListBody" Koa.query "plugin", schema: String
Koa.response 200, ref: "$pluginList" Koa.query "query", schema: String
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"chapters?" => [{
"id" => String,
"title" => String,
}],
"title" => String?,
}
get "/api/admin/plugin/list" do |env| get "/api/admin/plugin/list" do |env|
begin begin
query = env.params.query["query"].as String query = env.params.query["query"].as String
@@ -622,6 +521,7 @@ struct APIRouter
"title" => title, "title" => title,
}.to_json }.to_json
rescue e rescue e
Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -630,9 +530,19 @@ struct APIRouter
end end
Koa.describe "Adds a list of chapters from a plugin to the download queue" Koa.describe "Adds a list of chapters from a plugin to the download queue"
Koa.tag "admin" Koa.tags ["admin", "downloader"]
Koa.body ref: "$pluginDownload" Koa.body schema: {
Koa.response 200, ref: "$successFailCount" "plugin" => String,
"title" => String,
"chapters" => [{
"id" => String,
"title" => String,
}],
}
Koa.response 200, schema: {
"success" => Int32,
"fail" => Int32,
}
post "/api/admin/plugin/download" do |env| post "/api/admin/plugin/download" do |env|
begin begin
plugin = Plugin.new env.params.json["plugin"].as String plugin = Plugin.new env.params.json["plugin"].as String
@@ -655,6 +565,7 @@ struct APIRouter
"fail": jobs.size - inserted_count, "fail": jobs.size - inserted_count,
}.to_json }.to_json
rescue e rescue e
Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -665,24 +576,43 @@ struct APIRouter
Koa.describe "Returns the image dimensions of all pages in an entry" Koa.describe "Returns the image dimensions of all pages in an entry"
Koa.path "tid", desc: "A title ID" Koa.path "tid", desc: "A title ID"
Koa.path "eid", desc: "An entry ID" Koa.path "eid", desc: "An entry ID"
Koa.response 200, ref: "$dimensionResult" Koa.tag "reader"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"dimensions?" => [{
"width" => 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
"margin" => Config.current.page_margin, ""
}.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
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -693,8 +623,9 @@ struct APIRouter
Koa.describe "Downloads an entry" Koa.describe "Downloads an entry"
Koa.path "tid", desc: "A title ID" Koa.path "tid", desc: "A title ID"
Koa.path "eid", desc: "An entry ID" Koa.path "eid", desc: "An entry ID"
Koa.response 200, ref: "$binary" Koa.response 200, schema: Bytes
Koa.response 404, "Entry not found" Koa.response 404, "Entry not found"
Koa.tags ["library", "reader"]
get "/api/download/:tid/:eid" do |env| get "/api/download/:tid/:eid" do |env|
begin begin
title = (Library.default.get_title env.params.url["tid"]).not_nil! title = (Library.default.get_title env.params.url["tid"]).not_nil!
@@ -709,7 +640,12 @@ struct APIRouter
Koa.describe "Gets the tags of a title" Koa.describe "Gets the tags of a title"
Koa.path "tid", desc: "A title ID" Koa.path "tid", desc: "A title ID"
Koa.response 200, ref: "$tagsResult" Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"tags" => [String?],
}
Koa.tags ["library", "tags"]
get "/api/tags/:tid" do |env| get "/api/tags/:tid" do |env|
begin begin
title = (Library.default.get_title env.params.url["tid"]).not_nil! title = (Library.default.get_title env.params.url["tid"]).not_nil!
@@ -729,7 +665,12 @@ struct APIRouter
end end
Koa.describe "Returns all tags" Koa.describe "Returns all tags"
Koa.response 200, ref: "$tagsResult" Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"tags" => [String?],
}
Koa.tags ["library", "tags"]
get "/api/tags" do |env| get "/api/tags" do |env|
begin begin
tags = Storage.default.list_tags tags = Storage.default.list_tags
@@ -748,8 +689,8 @@ struct APIRouter
Koa.describe "Adds a new tag to a title" Koa.describe "Adds a new tag to a title"
Koa.path "tid", desc: "A title ID" Koa.path "tid", desc: "A title ID"
Koa.response 200, ref: "$result" Koa.response 200, schema: "result"
Koa.tag "admin" Koa.tags ["admin", "library", "tags"]
put "/api/admin/tags/:tid/:tag" do |env| put "/api/admin/tags/:tid/:tag" do |env|
begin begin
title = (Library.default.get_title env.params.url["tid"]).not_nil! title = (Library.default.get_title env.params.url["tid"]).not_nil!
@@ -771,8 +712,8 @@ struct APIRouter
Koa.describe "Deletes a tag from a title" Koa.describe "Deletes a tag from a title"
Koa.path "tid", desc: "A title ID" Koa.path "tid", desc: "A title ID"
Koa.response 200, ref: "$result" Koa.response 200, schema: "result"
Koa.tag "admin" Koa.tags ["admin", "library", "tags"]
delete "/api/admin/tags/:tid/:tag" do |env| delete "/api/admin/tags/:tid/:tag" do |env|
begin begin
title = (Library.default.get_title env.params.url["tid"]).not_nil! title = (Library.default.get_title env.params.url["tid"]).not_nil!
@@ -793,8 +734,16 @@ struct APIRouter
end end
Koa.describe "Lists all missing titles" Koa.describe "Lists all missing titles"
Koa.response 200, ref: "$missingResult" Koa.response 200, schema: {
Koa.tag "admin" "success" => Bool,
"error" => String?,
"titles?" => [{
"path" => String,
"id" => String,
"signature" => String,
}],
}
Koa.tags ["admin", "library"]
get "/api/admin/titles/missing" do |env| get "/api/admin/titles/missing" do |env|
begin begin
send_json env, { send_json env, {
@@ -803,6 +752,7 @@ struct APIRouter
"titles" => Storage.default.missing_titles, "titles" => Storage.default.missing_titles,
}.to_json }.to_json
rescue e rescue e
Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -811,8 +761,16 @@ struct APIRouter
end end
Koa.describe "Lists all missing entries" Koa.describe "Lists all missing entries"
Koa.response 200, ref: "$missingResult" Koa.response 200, schema: {
Koa.tag "admin" "success" => Bool,
"error" => String?,
"entries?" => [{
"path" => String,
"id" => String,
"signature" => String,
}],
}
Koa.tags ["admin", "library"]
get "/api/admin/entries/missing" do |env| get "/api/admin/entries/missing" do |env|
begin begin
send_json env, { send_json env, {
@@ -821,6 +779,7 @@ struct APIRouter
"entries" => Storage.default.missing_entries, "entries" => Storage.default.missing_entries,
}.to_json }.to_json
rescue e rescue e
Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -829,8 +788,8 @@ struct APIRouter
end end
Koa.describe "Deletes all missing titles" Koa.describe "Deletes all missing titles"
Koa.response 200, ref: "$result" Koa.response 200, schema: "result"
Koa.tag "admin" Koa.tags ["admin", "library"]
delete "/api/admin/titles/missing" do |env| delete "/api/admin/titles/missing" do |env|
begin begin
Storage.default.delete_missing_title Storage.default.delete_missing_title
@@ -839,6 +798,7 @@ struct APIRouter
"error" => nil, "error" => nil,
}.to_json }.to_json
rescue e rescue e
Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -847,8 +807,8 @@ struct APIRouter
end end
Koa.describe "Deletes all missing entries" Koa.describe "Deletes all missing entries"
Koa.response 200, ref: "$result" Koa.response 200, schema: "result"
Koa.tag "admin" Koa.tags ["admin", "library"]
delete "/api/admin/entries/missing" do |env| delete "/api/admin/entries/missing" do |env|
begin begin
Storage.default.delete_missing_entry Storage.default.delete_missing_entry
@@ -857,6 +817,7 @@ struct APIRouter
"error" => nil, "error" => nil,
}.to_json }.to_json
rescue e rescue e
Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -867,8 +828,8 @@ struct APIRouter
Koa.describe "Deletes a missing title identified by `tid`", <<-MD Koa.describe "Deletes a missing title identified by `tid`", <<-MD
Does nothing if the given `tid` is not found or if the title is not missing. Does nothing if the given `tid` is not found or if the title is not missing.
MD MD
Koa.response 200, ref: "$result" Koa.response 200, schema: "result"
Koa.tag "admin" Koa.tags ["admin", "library"]
delete "/api/admin/titles/missing/:tid" do |env| delete "/api/admin/titles/missing/:tid" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
@@ -878,6 +839,7 @@ struct APIRouter
"error" => nil, "error" => nil,
}.to_json }.to_json
rescue e rescue e
Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -888,8 +850,8 @@ struct APIRouter
Koa.describe "Deletes a missing entry identified by `eid`", <<-MD Koa.describe "Deletes a missing entry identified by `eid`", <<-MD
Does nothing if the given `eid` is not found or if the entry is not missing. Does nothing if the given `eid` is not found or if the entry is not missing.
MD MD
Koa.response 200, ref: "$result" Koa.response 200, schema: "result"
Koa.tag "admin" Koa.tags ["admin", "library"]
delete "/api/admin/entries/missing/:eid" do |env| delete "/api/admin/entries/missing/:eid" do |env|
begin begin
eid = env.params.url["eid"] eid = env.params.url["eid"]
@@ -899,6 +861,7 @@ struct APIRouter
"error" => nil, "error" => nil,
}.to_json }.to_json
rescue e rescue e
Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
+3 -7
View File
@@ -30,7 +30,8 @@ struct MainRouter
else else
redirect env, "/" redirect env, "/"
end end
rescue rescue e
Logger.error e
redirect env, "/login" redirect env, "/login"
end end
end end
@@ -71,11 +72,6 @@ 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"]? id = env.params.query["plugin"]?
@@ -103,7 +99,7 @@ struct MainRouter
recently_added = Library.default.get_recently_added_entries username recently_added = Library.default.get_recently_added_entries username
start_reading = Library.default.get_start_reading_titles username start_reading = Library.default.get_start_reading_titles username
titles = Library.default.titles titles = Library.default.titles
new_user = !titles.any? { |t| t.load_percentage(username) > 0 } new_user = !titles.any? &.load_percentage(username).> 0
empty_library = titles.size == 0 empty_library = titles.size == 0
layout "home" layout "home"
rescue e rescue e
+11 -4
View File
@@ -30,6 +30,11 @@ struct ReaderRouter
title = (Library.default.get_title env.params.url["title"]).not_nil! title = (Library.default.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil! entry = (title.get_entry env.params.url["entry"]).not_nil!
sort_opt = SortOptions.from_info_json title.dir, username
get_sort_opt
entries = title.sorted_entries username, sort_opt
page_idx = env.params.url["page"].to_i page_idx = env.params.url["page"].to_i
if page_idx > entry.pages || page_idx <= 0 if page_idx > entry.pages || page_idx <= 0
raise "Page #{page_idx} not found." raise "Page #{page_idx} not found."
@@ -37,10 +42,12 @@ struct ReaderRouter
exit_url = "#{base_url}book/#{title.id}" exit_url = "#{base_url}book/#{title.id}"
next_entry_url = nil next_entry_url = entry.next_entry(username).try do |e|
next_entry = entry.next_entry username "#{base_url}reader/#{title.id}/#{e.id}"
unless next_entry.nil? end
next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}"
previous_entry_url = entry.previous_entry(username).try do |e|
"#{base_url}reader/#{title.id}/#{e.id}"
end end
render "src/views/reader.html.ecr" render "src/views/reader.html.ecr"
+1
View File
@@ -49,6 +49,7 @@ class Server
{% if flag?(:release) %} {% if flag?(:release) %}
Kemal.config.env = "production" Kemal.config.env = "production"
{% end %} {% end %}
Kemal.config.host_binding = Config.current.host
Kemal.config.port = Config.current.port Kemal.config.port = Config.current.port
Kemal.run Kemal.run
end end
+34 -11
View File
@@ -34,7 +34,7 @@ class Storage
dir = File.dirname @path dir = File.dirname @path
unless Dir.exists? dir unless Dir.exists? dir
Logger.info "The DB directory #{dir} does not exist. " \ Logger.info "The DB directory #{dir} does not exist. " \
"Attepmting to create it" "Attempting to create it"
Dir.mkdir_p dir Dir.mkdir_p dir
end end
MainFiber.run do MainFiber.run do
@@ -48,14 +48,6 @@ class Storage
user_count = db.query_one "select count(*) from users", as: Int32 user_count = db.query_one "select count(*) from users", as: Int32
init_admin if init_user && user_count == 0 init_admin if init_user && user_count == 0
# Verifies that the default username in config is valid
if Config.current.disable_login
username = Config.current.default_username
unless username_exists username
raise "Default username #{username} does not exist"
end
end
end end
unless @auto_close unless @auto_close
@db = DB.open "sqlite3://#{@path}" @db = DB.open "sqlite3://#{@path}"
@@ -453,7 +445,7 @@ class Storage
Logger.debug "Marking #{trash_ids.size} entries as unavailable" Logger.debug "Marking #{trash_ids.size} entries as unavailable"
end end
db.exec "update ids set unavailable = 1 where id in " \ db.exec "update ids set unavailable = 1 where id in " \
"(#{trash_ids.map { |i| "'#{i}'" }.join ","})" "(#{trash_ids.join "," { |i| "'#{i}'" }})"
# Detect dangling title IDs # Detect dangling title IDs
trash_titles = [] of String trash_titles = [] of String
@@ -469,7 +461,7 @@ class Storage
Logger.debug "Marking #{trash_titles.size} titles as unavailable" Logger.debug "Marking #{trash_titles.size} titles as unavailable"
end end
db.exec "update titles set unavailable = 1 where id in " \ db.exec "update titles set unavailable = 1 where id in " \
"(#{trash_titles.map { |i| "'#{i}'" }.join ","})" "(#{trash_titles.join "," { |i| "'#{i}'" }})"
end end
end end
end end
@@ -522,6 +514,37 @@ class Storage
delete_missing "titles", id delete_missing "titles", id
end end
def save_md_token(username : String, token : String, expire : Time)
MainFiber.run do
get_db do |db|
count = db.query_one "select count(*) from md_account where " \
"username = (?)", username, as: Int64
if count == 0
db.exec "insert into md_account values (?, ?, ?)", username, token,
expire.to_unix
else
db.exec "update md_account set token = (?), expire = (?) " \
"where username = (?)", token, expire.to_unix, username
end
end
end
end
def get_md_token(username) : Tuple(String?, Time?)
token = nil
expires = nil
MainFiber.run do
get_db do |db|
db.query_one? "select token, expire from md_account where " \
"username = (?)", username do |res|
token = res.read String
expires = Time.unix res.read Int64
end
end
end
{token, expires}
end
def close def close
MainFiber.run do MainFiber.run do
unless @db.nil? unless @db.nil?
+83
View File
@@ -0,0 +1,83 @@
require "db"
require "json"
struct Subscription
include DB::Serializable
include JSON::Serializable
getter id : Int64 = 0
getter username : String
getter manga_id : Int64
property language : String?
property group_id : Int64?
property min_volume : Int64?
property max_volume : Int64?
property min_chapter : Int64?
property max_chapter : Int64?
@[DB::Field(key: "last_checked")]
@[JSON::Field(key: "last_checked")]
@raw_last_checked : Int64
@[DB::Field(key: "created_at")]
@[JSON::Field(key: "created_at")]
@raw_created_at : Int64
def last_checked : Time
Time.unix @raw_last_checked
end
def created_at : Time
Time.unix @raw_created_at
end
def initialize(@manga_id, @username)
@raw_created_at = Time.utc.to_unix
@raw_last_checked = Time.utc.to_unix
end
private def in_range?(value : String, lowerbound : Int64?,
upperbound : Int64?) : Bool
lb = lowerbound.try &.to_f64
ub = upperbound.try &.to_f64
return true if lb.nil? && ub.nil?
v = value.to_f64?
return false unless v
if lb.nil?
v <= ub.not_nil!
elsif ub.nil?
v >= lb.not_nil!
else
v >= lb.not_nil! && v <= ub.not_nil!
end
end
def match?(chapter : MangaDex::Chapter) : Bool
if chapter.manga_id != manga_id ||
(language && chapter.language != language) ||
(group_id && !chapter.groups.map(&.id).includes? group_id)
return false
end
in_range?(chapter.volume, min_volume, max_volume) &&
in_range?(chapter.chapter, min_chapter, max_chapter)
end
def check_for_updates : Int32
Logger.debug "Checking updates for subscription with ID #{id}"
jobs = [] of Queue::Job
get_client(username).user.updates_after last_checked do |chapter|
next unless match? chapter
jobs << chapter.to_job
end
Storage.default.update_subscription_last_checked id
count = Queue.default.push jobs
Logger.debug "#{count}/#{jobs.size} of updates added to queue"
count
rescue e
Logger.error "Error occurred when checking updates for " \
"subscription with ID #{id}. #{e}"
0
end
end
+1 -1
View File
@@ -73,7 +73,7 @@ class ChapterSorter
.select do |key| .select do |key|
keys[key].count >= str_ary.size / 2 keys[key].count >= str_ary.size / 2
end end
.sort do |a_key, b_key| .sort! do |a_key, b_key|
a = keys[a_key] a = keys[a_key]
b = keys[b_key] b = keys[b_key]
# Sort keys by the number of times they appear # Sort keys by the number of times they appear
+1 -1
View File
@@ -11,7 +11,7 @@ end
def split_by_alphanumeric(str) def split_by_alphanumeric(str)
arr = [] of String arr = [] of String
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match| str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
arr += match.captures.select { |s| s != "" } arr += match.captures.select &.!= ""
end end
arr arr
end end
+10 -2
View File
@@ -1,7 +1,7 @@
IMGS_PER_PAGE = 5 IMGS_PER_PAGE = 5
ENTRIES_IN_HOME_SECTIONS = 8 ENTRIES_IN_HOME_SECTIONS = 8
UPLOAD_URL_PREFIX = "/uploads" UPLOAD_URL_PREFIX = "/uploads"
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"] STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt)
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"] SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
def random_str def random_str
@@ -23,10 +23,18 @@ end
def register_mime_types def register_mime_types
{ {
# Comic Archives
".zip" => "application/zip", ".zip" => "application/zip",
".rar" => "application/x-rar-compressed", ".rar" => "application/x-rar-compressed",
".cbz" => "application/vnd.comicbook+zip", ".cbz" => "application/vnd.comicbook+zip",
".cbr" => "application/vnd.comicbook-rar", ".cbr" => "application/vnd.comicbook-rar",
# Favicon
".ico" => "image/x-icon",
# FontAwesome fonts
".woff" => "font/woff",
".woff2" => "font/woff2",
}.each do |k, v| }.each do |k, v|
MIME.register k, v MIME.register k, v
end end
@@ -106,7 +114,7 @@ class String
def components_similarity(other : String) : Float64 def components_similarity(other : String) : Float64
s, l = [self, other] s, l = [self, other]
.map { |str| Path.new(str).parts } .map { |str| Path.new(str).parts }
.sort_by &.size .sort_by! &.size
match = s.reverse.zip(l.reverse).count { |a, b| a == b } match = s.reverse.zip(l.reverse).count { |a, b| a == b }
match / s.size match / s.size
+13 -11
View File
@@ -1,23 +1,23 @@
# Web related helper functions/macros # Web related helper functions/macros
# This macro defines `is_admin` when used def is_admin?(env) : Bool
macro check_admin_access
is_admin = false is_admin = false
# The token (if exists) takes precedence over the default user option. if !Config.current.auth_proxy_header_name.empty? ||
# this is why we check the default username first before checking the Config.current.disable_login
# token. is_admin = Storage.default.username_is_admin get_username env
if Config.current.disable_login
is_admin = Storage.default.
username_is_admin Config.current.default_username
end end
# The token (if exists) takes precedence over other authentication methods.
if token = env.session.string? "token" if token = env.session.string? "token"
is_admin = Storage.default.verify_admin token is_admin = Storage.default.verify_admin token
end end
is_admin
end end
macro layout(name) macro layout(name)
base_url = Config.current.base_url base_url = Config.current.base_url
check_admin_access is_admin = is_admin? env
begin begin
page = {{name}} page = {{name}}
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr" render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
@@ -32,7 +32,7 @@ end
macro send_error_page(msg) macro send_error_page(msg)
message = {{msg}} message = {{msg}}
base_url = Config.current.base_url base_url = Config.current.base_url
check_admin_access is_admin = is_admin? env
page = "Error" page = "Error"
html = render "src/views/message.html.ecr", "src/views/layout.html.ecr" html = render "src/views/message.html.ecr", "src/views/layout.html.ecr"
send_file env, html.to_slice, "text/html" send_file env, html.to_slice, "text/html"
@@ -49,6 +49,8 @@ macro get_username(env)
rescue e rescue e
if Config.current.disable_login if Config.current.disable_login
Config.current.default_username Config.current.default_username
elsif (header = Config.current.auth_proxy_header_name) && !header.empty?
env.request.headers[header]
else else
raise e raise e
end end
@@ -70,7 +72,7 @@ def redirect(env, path)
end end
def hash_to_query(hash) def hash_to_query(hash)
hash.map { |k, v| "#{k}=#{v}" }.join("&") hash.join "&" { |k, v| "#{k}=#{v}" }
end end
def request_path_startswith(env, ary) def request_path_startswith(env, ary)
+1 -4
View File
@@ -4,13 +4,10 @@
<title>Mango - <%= page.split("-").map(&.capitalize).join(" ") %></title> <title>Mango - <%= page.split("-").map(&.capitalize).join(" ") %></title>
<meta name="description" content="Mango - Manga Server and Web Reader"> <meta name="description" content="Mango - Manga Server and Web Reader">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="<%= base_url %>css/uikit.css" />
<link rel="stylesheet" href="<%= base_url %>css/mango.css" /> <link rel="stylesheet" href="<%= base_url %>css/mango.css" />
<link rel="icon" href="<%= base_url %>favicon.ico"> <link rel="icon" href="<%= base_url %>favicon.ico">
<script src="https://polyfill.io/v3/polyfill.min.js?features=matchMedia%2Cdefault&flags=gated"></script> <script src="https://polyfill.io/v3/polyfill.min.js?features=MutationObserver%2Cdefault%2CmatchMedia&flats=gated"></script>
<script defer src="<%= base_url %>js/fontawesome.min.js"></script>
<script defer src="<%= base_url %>js/solid.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine.min.js"></script> <script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine.min.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine-ie11.min.js" defer></script> <script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine-ie11.min.js" defer></script>
+1
View File
@@ -0,0 +1 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
+1
View File
@@ -0,0 +1 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
+2
View File
@@ -0,0 +1,2 @@
<script src="https://cdn.jsdelivr.net/npm/uikit@3.5.9/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.5.9/dist/js/uikit-icons.min.js"></script>
+6 -6
View File
@@ -25,14 +25,14 @@
<td x-text="job.title"></td> <td x-text="job.title"></td>
</template> </template>
<template x-if="!job.plugin_id"> <template x-if="!job.plugin_id">
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/chapter/${job.id}`" x-text="job.title"></td> <td><a :href="`<%= mangadex_base_url %>/chapter/${job.id}`" x-text="job.title"></td>
</template> </template>
<template x-if="job.plugin_id"> <template x-if="job.plugin_id">
<td x-text="job.manga_title"></td> <td x-text="job.manga_title"></td>
</template> </template>
<template x-if="!job.plugin_id"> <template x-if="!job.plugin_id">
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/manga/${job.manga_id}`" x-text="job.manga_title"></td> <td><a :href="`<%= mangadex_base_url %>/manga/${job.manga_id}`" x-text="job.manga_title"></td>
</template> </template>
<td x-text="`${job.success_count}/${job.pages}`"></td> <td x-text="`${job.success_count}/${job.pages}`"></td>
@@ -49,11 +49,10 @@
</td> </td>
<td x-text="`${job.plugin_id || ''}`"></td> <td x-text="`${job.plugin_id || ''}`"></td>
<td> <td>
<a @click="jobAction('delete', $event)" uk-icon="trash"></a> <a @click="jobAction('delete', $event)" uk-icon="trash" uk-tooltip="Delete"></a>
<template x-if="job.status_message.length > 0"> <template x-if="job.status_message.length > 0">
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a> <a @click="jobAction('retry', $event)" uk-icon="refresh" uk-tooltip="Retry"></a>
</template> </template>
</td> </td>
</tr> </tr>
@@ -61,9 +60,10 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<% content_for "script" do %> <% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script> <%= render_component "moment" %>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/download-manager.js"></script> <script src="<%= base_url %>js/download-manager.js"></script>
<% end %> <% end %>
+144 -65
View File
@@ -1,83 +1,162 @@
<h2 class=uk-title>Download from MangaDex</h2> <h2 class=uk-title>Download from MangaDex</h2>
<div class="uk-grid-small" uk-grid> <div x-data="downloadComponent()" x-init="init()">
<div class="uk-width-3-4"> <div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
<input id="search-input" class="uk-input" type="text" placeholder="MangaDex manga ID or URL"> <div class="uk-width-expand">
<input class="uk-input" type="text" :placeholder="searchAvailable ? 'Search MangaDex or enter a manga ID/URL' : 'MangaDex manga ID or URL'" x-model="searchInput" @keydown.enter.debounce="search()">
</div>
<div class="uk-width-auto">
<div uk-spinner class="uk-align-center" x-show="loading" x-cloak></div>
<button class="uk-button uk-button-default" x-show="!loading" @click="search()">Search</button>
</div>
</div> </div>
<div class="uk-width-1-4">
<div id="spinner" uk-spinner class="uk-align-center" hidden></div> <template x-if="mangaAry">
<button id="search-btn" class="uk-button uk-button-default" onclick="search()">Search</button> <div>
</div> <p x-show="mangaAry.length === 0">No matching manga found.</p>
</div>
<div class"uk-grid-small" uk-grid hidden id="manga-details"> <div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<div class="uk-width-1-4@s"> <template x-for="manga in mangaAry" :key="manga.id">
<img id="cover"> <div class="item" :data-id="manga.id" @click="chooseManga(manga)">
</div> <div class="uk-card uk-card-default">
<div class="uk-width-1-4@s"> <div class="uk-card-media-top uk-inline">
<p id="title"></p> <img uk-img :data-src="manga.mainCover">
<p id="artist"></p> </div>
<p id="author"></p> <div class="uk-card-body">
</div> <h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="manga.title"></h3>
<div id="filter-form" class="uk-form-stacked uk-width-1-2@s" hidden> <p class="uk-text-meta" x-text="`ID: ${manga.id}`"></p>
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p> </div>
<p class="uk-text-meta uk-margin-remove-top" id="count-text"></p> </div>
<div class="uk-margin"> </div>
<label class="uk-form-label" for="lang-select">Language</label> </template>
<div class="uk-form-controls">
<select class="uk-select filter-field" id="lang-select">
</select>
</div> </div>
</div> </div>
<div class="uk-margin"> </template>
<label class="uk-form-label" for="group-select">Group</label>
<div class="uk-form-controls"> <div x-show="data && data.chapters" x-cloak>
<select class="uk-select filter-field" id="group-select"> <div class"uk-grid-small" uk-grid>
</select> <div class="uk-width-1-4@s">
<img :src="data.mainCover">
</div>
<div class="uk-width-1-4@s">
<p>Title: <a :href="`<%= mangadex_base_url %>/manga/${data.id}`" x-text="data.title"></a></p>
<p x-text="`Artist: ${data.artist}`"></p>
<p x-text="`Author: ${data.author}`"></p>
</div>
<div class="uk-form-stacked uk-width-1-2@s" id="filters">
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
<p class="uk-text-meta uk-margin-remove-top" x-text="`${chapters.length} chapters found`"></p>
<div class="uk-margin">
<label class="uk-form-label">Language</label>
<div class="uk-form-controls">
<select class="uk-select filter-field" x-model="langChoice" @change="filtersUpdated()">
<template x-for="lang in languages" :key="lang">
<option x-text="lang"></option>
</template>
</select>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Group</label>
<div class="uk-form-controls">
<select class="uk-select filter-field" x-model="groupChoice" @change="filtersUpdated()">
<template x-for="group in groups" :key="group">
<option x-text="group"></option>
</template>
</select>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Volume</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="volumeRange" @keydown.enter="filtersUpdated()">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Chapter</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="chapterRange" @keydown.enter="filtersUpdated()">
</div>
</div>
</div> </div>
</div> </div>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="volume-range">Volume</label> <div class="uk-margin">
<div class="uk-form-controls"> <button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
<input class="uk-input filter-field" type="text" id="volume-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty."> <button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
<button class="uk-button uk-button-primary" @click="download()" x-show="!addingToDownload">Download Selected</button>
<div uk-spinner class="uk-margin-left" x-show="addingToDownload"></div>
</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> </div>
<div class="uk-margin"> <p x-text="`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters. Please use the filter options above to narrow down your search.`" x-show="chapters.length > chaptersLimit"></p>
<label class="uk-form-label" for="chapter-range">Chapter</label> <table class="uk-table uk-table-striped uk-overflow-auto" x-show="chapters.length <= chaptersLimit">
<div class="uk-form-controls"> <thead>
<input class="uk-input filter-field" type="text" id="chapter-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty."> <tr>
<th>ID</th>
<th>Title</th>
<th>Language</th>
<th>Group</th>
<th>Volume</th>
<th>Chapter</th>
<th>Timestamp</th>
</tr>
</thead>
<template x-if="chapters.length <= chaptersLimit">
<tbody id="selectable">
<template x-for="chp in chapters" :key="chp">
<tr class="ui-widget-content">
<td><a :href="`<%= mangadex_base_url %>/chapter/${chp.id}`" x-text="chp.id"></a></td>
<td x-text="chp.title"></td>
<td x-text="chp.language"></td>
<td>
<template x-for="grp in Object.entries(chp.groups)">
<div>
<a :href="`<%= mangadex_base_url %>/group/${grp[1]}`" x-text="grp[0]"></a>
</div>
</template>
</td>
<td x-text="chp.volume"></td>
<td x-text="chp.chapter"></td>
<td x-text="`${moment.unix(chp.timestamp).fromNow()}`"></td>
</tr>
</template>
</tbody>
</template>
</table>
</div>
<div id="modal" class="uk-flex-top" uk-modal="container: false">
<div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<h3 class="uk-modal-title break-word" x-text="candidateManga.title"></h3>
</div>
<div class="uk-modal-body">
<div class="uk-grid">
<div class="uk-width-1-3@s">
<img uk-img data-width data-height :src="candidateManga.mainCover" style="width:100%;margin-bottom:10px;">
<a :href="`<%= mangadex_base_url %>/manga/${candidateManga.id}`" x-text="`ID: ${candidateManga.id}`" class="uk-link-muted"></a>
</div>
<div class="uk-width-2-3@s" uk-overflow-auto>
<p x-text="candidateManga.description"></p>
</div>
</div>
</div>
<div class="uk-modal-footer">
<button class="uk-button uk-button-primary" type="button" @click="confirmManga(candidateManga.id)">Choose</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div id="selection-controls" class="uk-margin" hidden>
<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>
<p id="filter-notification" hidden></p>
<table class="uk-table uk-table-striped uk-overflow-auto" hidden>
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Language</th>
<th>Group</th>
<th>Volume</th>
<th>Chapter</th>
<th>Timestamp</th>
</tr>
</thead>
</table>
<% content_for "script" do %> <% content_for "script" do %>
<script> <%= render_component "moment" %>
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, ""); <%= render_component "jquery-ui" %>
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/download.js"></script> <script src="<%= base_url %>js/download.js"></script>
<% end %> <% end %>
+1 -1
View File
@@ -77,7 +77,7 @@
<%- end -%> <%- end -%>
<% content_for "script" do %> <% content_for "script" do %>
<%= render_component "dots-scripts" %> <%= render_component "dots" %>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/title.js"></script> <script src="<%= base_url %>js/title.js"></script>
<% end %> <% end %>
+1 -5
View File
@@ -17,7 +17,6 @@
<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>
@@ -49,7 +48,6 @@
<div class="uk-navbar-dropdown"> <div class="uk-navbar-dropdown">
<ul class="uk-nav uk-navbar-dropdown-nav"> <ul class="uk-nav uk-navbar-dropdown-nav">
<li class="uk-nav-header">Source</li> <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><a href="<%= base_url %>download/plugins">Plugins</a></li>
<li class="uk-nav-divider"></li> <li class="uk-nav-divider"></li>
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li> <li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
@@ -82,9 +80,7 @@
setTheme(); setTheme();
const base_url = "<%= base_url %>"; const base_url = "<%= base_url %>";
</script> </script>
<script src="<%= base_url %>js/uikit.min.js"></script> <%= render_component "uikit" %>
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
<%= yield_content "script" %> <%= yield_content "script" %>
</body> </body>
+1 -1
View File
@@ -24,7 +24,7 @@
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>
<%= render_component "dots-scripts" %> <%= render_component "dots" %>
<script src="<%= base_url %>js/search.js"></script> <script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script> <script src="<%= base_url %>js/sort-items.js"></script>
<% end %> <% end %>
+1 -2
View File
@@ -30,8 +30,7 @@
<script> <script>
setTheme(); setTheme();
</script> </script>
<script src="<%= base_url %>js/uikit.min.js"></script> <%= render_component "uikit" %>
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
</body> </body>
</html> </html>
+39
View File
@@ -0,0 +1,39 @@
<div x-data="component()" x-init="init()">
<h2 class="uk-title">Connect to MangaDex</h2>
<div class"uk-grid-small" uk-grid x-show="!loading" x-cloak>
<div class="uk-width-1-2@s" x-show="!expires">
<p>This step is optional but highly recommended if you are using the MangaDex downloader. Connecting to MangaDex allows you to:</p>
<ul>
<li>Search MangaDex by search terms in addition to manga IDs</li>
<li>Automatically download new chapters when they are available (coming soon)</li>
</ul>
</div>
<div class="uk-width-1-2@s" x-show="expires">
<p>
<span x-show="!expired">You have logged in to MangaDex!</span>
<span x-show="expired">You have logged in to MangaDex but the token has expired.</span>
The expiration date of your token is <code x-text="moment.unix(expires).format('MMMM Do YYYY, HH:mm:ss')"></code>.
<span x-show="!expired">If the integration is not working, you</span>
<span x-show="expired">You</span>
can log in again and the token will be updated.
</p>
</div>
<div class="uk-width-1-2@s">
<div class="uk-margin">
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:user"></span><input class="uk-input uk-form-large" type="text" x-model="username" @keydown.enter.debounce="login()"></div>
</div>
<div class="uk-margin">
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:lock"></span><input class="uk-input uk-form-large" type="password" x-model="password" @keydown.enter.debounce="login()"></div>
</div>
<div class="uk-margin"><button class="uk-button uk-button-primary uk-button-large uk-width-1-1" @click="login()" :disabled="loggingIn">Login to MangaDex</button></div>
</div>
</div>
</div>
<% content_for "script" do %>
<%= render_component "moment" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/mangadex.js"></script>
<% end %>
+5 -3
View File
@@ -56,8 +56,10 @@
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div> <div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
</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> <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>
<table class="uk-table uk-table-striped uk-overflow-auto tablesorter"> <div class="uk-overflow-auto">
</table> <table class="uk-table uk-table-striped tablesorter">
</table>
</div>
</div> </div>
<% end %> <% end %>
@@ -68,7 +70,7 @@
var pid = "<%= plugin.not_nil!.info.id %>"; var pid = "<%= plugin.not_nil!.info.id %>";
</script> </script>
<% end %> <% end %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script> <%= 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="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>
+58 -11
View File
@@ -21,22 +21,22 @@
<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" :class="{'uk-align-center': true, 'spine': item.width < 50}"
:style="item.style"
:data-src="item.url" :data-src="item.url"
:width="item.width" :width="item.width"
:height="item.height" :height="item.height"
:id="item.id" :id="item.id"
:style="`margin-top:${margin}px; margin-bottom:${margin}px`"
@click="showControl($event)" @click="showControl($event)"
/> />
</template> </template>
<%- if next_entry_url -%> <%- if next_entry_url -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="nextEntry('<%= next_entry_url %>')">Next Entry</button> <button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="nextEntry('<%= next_entry_url %>')">Next Entry</button>
<%- else -%> <%- else -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="exitReader('<%= exit_url %>', true)">Exit Reader</button> <button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="exitReader('<%= exit_url %>')">Exit Reader</button>
<%- end -%> <%- end -%>
</div> </div>
@@ -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>
@@ -68,18 +71,19 @@
</div> </div>
<div class="uk-modal-body"> <div class="uk-modal-body">
<div class="uk-margin"> <div class="uk-margin">
<p id="progress-label"></p> <p x-text="`Progress: ${selectedIndex}/${items.length} (${(selectedIndex/items.length * 100).toFixed(1)}%)`"></p>
</div> </div>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="page-select">Jump to page</label> <label class="uk-form-label" for="page-select">Jump to Page</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
<select id="page-select" class="uk-select" @change="pageChanged()"> <select id="page-select" class="uk-select" @change="pageChanged()" x-model="selectedIndex">
<%- (1..entry.pages).each do |p| -%> <%- (1..entry.pages).each do |p| -%>
<option value="<%= p %>"><%= p %></option> <option value="<%= p %>"><%= p %></option>
<%- end -%> <%- end -%>
</select> </select>
</div> </div>
</div> </div>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="mode-select">Mode</label> <label class="uk-form-label" for="mode-select">Mode</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
@@ -89,9 +93,53 @@
</select> </select>
</div> </div>
</div> </div>
<div class="uk-margin" x-show="mode === 'continuous'">
<label class="uk-form-label" for="margin-range" x-text="`Page Margin: ${margin}px`"></label>
<div class="uk-form-controls">
<input id="margin-range" class="uk-range" type="range" min="0" max="50" step="5" x-model="margin" @change="marginChanged()">
</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">
<div class="uk-margin">
<label class="uk-form-label" for="entry-select">Jump to Entry</label>
<div class="uk-form-controls">
<select id="entry-select" class="uk-select" @change="entryChanged()">
<% entries.each do |e| %>
<option value="<%= e.id %>"
<% if e.id == entry.id %>
selected
<% end %>>
<%= e.title %>
</option>
<% end %>
</select>
</div>
</div>
</div> </div>
<div class="uk-modal-footer uk-text-right"> <div class="uk-modal-footer uk-text-right">
<button class="uk-button uk-button-danger" type="button" @click="exitReader('<%= exit_url %>')">Exit Reader</button> <% if previous_entry_url %>
<a class="uk-button uk-button-default uk-margin-small-bottom uk-margin-small-right" href="<%= previous_entry_url %>">Previous Entry</a>
<% end %>
<% if next_entry_url %>
<a class="uk-button uk-button-default uk-margin-small-bottom uk-margin-small-right" href="<%= next_entry_url %>">Next Entry</a>
<% end %>
<a class="uk-button uk-button-danger uk-margin-small-bottom uk-margin-small-right" href="<%= exit_url %>">Exit Reader</a>
</div> </div>
</div> </div>
</div> </div>
@@ -103,15 +151,14 @@
const eid = "<%= entry.id %>"; const eid = "<%= entry.id %>";
</script> </script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
<%= render_component "uikit" %>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/uikit.min.js"></script>
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
<script src="<%= base_url %>js/reader.js"></script> <script src="<%= base_url %>js/reader.js"></script>
</body> </body>
<style> <style>
img[data-src][src*='data:image'] { background: white; } img[data-src][src*='data:image'] { background: white; }
img { width: 100%; } img:not(.spine) { width: 100%; }
.reader-bg { background: black; } .reader-bg { background: black; }
</style> </style>
+54
View File
@@ -0,0 +1,54 @@
<h2 class="uk-title">MangaDex Subscription Manager</h2>
<div x-data="component()" x-init="init()">
<p x-show="available === false">The subscription manager uses a MangaDex API that requires authentication. Please <a href="<%= base_url %>admin/mangadex">connect to MangaDex</a> before using this feature.</p>
<p x-show="available && subscriptions.length === 0">No subscription found. Go to the <a href="<%= base_url %>download">MangaDex download page</a> and start subscribing.</p>
<template x-if="subscriptions.length > 0">
<div class="uk-overflow-auto">
<table class="uk-table uk-table-striped">
<thead>
<tr>
<th>Manga ID</th>
<th>Language</th>
<th>Group ID</th>
<th>Volume Range</th>
<th>Chapter Range</th>
<th>Creator</th>
<th>Last Checked</th>
<th>Created At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template x-for="sub in subscriptions" :key="sub">
<tr>
<td><a :href="`<%= mangadex_base_url %>/manga/${sub.manga_id}`" x-text="sub.manga_id"></a></td>
<td x-text="sub.language || 'All'"></td>
<td>
<a x-show="sub.group_id" :href="`<%= mangadex_base_url %>/group/${sub.group_id}`" x-text="sub.group_id"></a>
<span x-show="!sub.group_id">All</span>
</td>
<td x-text="formatRange(sub.min_volume, sub.max_volume)"></td>
<td x-text="formatRange(sub.min_chapter, sub.max_chapter)"></td>
<td x-text="sub.username"></td>
<td x-text="`${moment.unix(sub.last_checked).fromNow()}`"></td>
<td x-text="`${moment.unix(sub.created_at).fromNow()}`"></td>
<td :data-id="sub.id">
<a @click="check($event)" x-show="sub.username === '<%= username %>'" uk-icon="refresh" uk-tooltip="Check for updates"></a>
<a @click="rm($event)" x-show="sub.username === '<%= username %>'" uk-icon="trash" uk-tooltip="Delete"></a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
<% content_for "script" do %>
<%= render_component "moment" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/subscription.js"></script>
<% end %>
+1 -1
View File
@@ -24,7 +24,7 @@
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>
<%= render_component "dots-scripts" %> <%= render_component "dots" %>
<script src="<%= base_url %>js/search.js"></script> <script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script> <script src="<%= base_url %>js/sort-items.js"></script>
<% end %> <% end %>
+1 -1
View File
@@ -123,7 +123,7 @@
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>
<%= render_component "dots-scripts" %> <%= render_component "dots" %>
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/css/select2.min.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/css/select2.min.css" rel="stylesheet" />
<link href="<%= base_url %>css/tags.css" rel="stylesheet" /> <link href="<%= base_url %>css/tags.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/js/select2.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/js/select2.min.js"></script>