Compare commits

...

104 Commits

Author SHA1 Message Date
Alex Ling 30c0199039 Merge branch 'dev' 2020-12-26 07:33:04 +00:00
Alex Ling 7a7cb78f82 Check bool environment variables are "1" or "true" 2020-12-26 07:11:10 +00:00
Alex Ling 8931ba8c43 Bump version to 0.17.0 2020-12-26 04:02:58 +00:00
Alex Ling d50981c151 Fix typos 2020-12-26 04:01:57 +00:00
Alex Ling df4deb1415 Allow proxy with authentication 2020-12-22 02:32:07 +00:00
Alex Ling aa5e999ed4 Allow users to disable SSL verification 2020-12-21 06:04:26 +00:00
Alex Ling 84d4b0c529 Switch to redoc and update API description 2020-12-21 06:04:26 +00:00
Alex Ling d3e5691478 Add overall description of the API 2020-12-14 15:20:50 +00:00
Alex Ling 1000b02ae0 Exclude /src/routes/api.cr from line width check 2020-12-14 14:59:18 +00:00
Alex Ling 1f795889a9 Move the entry download route to API 2020-12-14 13:03:23 +00:00
Alex Ling d33b45233a Use the correct verbs in the API 2020-12-14 12:49:56 +00:00
Alex Ling 4f6df5b9a3 Rename bulk-progress to bulk_progress 2020-12-14 11:54:25 +00:00
Alex Ling 341b586cb3 Add API documentation 2020-12-14 11:09:38 +00:00
Alex Ling 9dcc9665ce Cancel a download job when deleted from web UI 2020-12-12 16:15:16 +00:00
Alex Ling 1cd90926df Bind boolean attributes 2020-12-11 10:22:08 +00:00
Alex Ling ac1ff61e6d Move theme.js to common.js
This reduces the number of JS files to include when loading
2020-12-11 10:11:39 +00:00
Alex Ling 6ea41f79e9 Simplify the showControl calls on reader page 2020-12-11 09:47:32 +00:00
Alex Ling dad02a2a30 Move getProp and setProp to common.js 2020-12-11 09:46:56 +00:00
Alex Ling 280490fb36 Rewrite the download manager page 2020-12-11 07:46:47 +00:00
Alex Ling 455315a362 Upgrade to Crystal 0.35.1 2020-12-11 07:46:47 +00:00
Alex Ling df51406638 Use $GITHUB_ENV [skip ci] 2020-11-24 13:43:54 +08:00
Alex Ling 531d42ef18 [skip ci] enable set-env
https://github.blog/changelog/2020-10-01-github-actions-deprecating-set-env-and-add-path-commands/
2020-11-24 13:38:15 +08:00
Alex Ling 2645e8cd05 Merge branch 'dev' 2020-11-24 05:31:06 +00:00
Alex Ling b2dc44a919 Reverse J and K for page navigation 2020-11-24 05:09:06 +00:00
Alex Ling c8db397a3b Bump version to v0.16.0 2020-11-24 04:30:47 +00:00
Alex Ling 6384d4b77a Log "DB optimization finished" in the info level 2020-11-24 04:05:07 +00:00
Alex Ling 1039732d87 Log the full file path in error messages (#123) 2020-11-24 04:03:53 +00:00
Alex Ling 011123f690 Allow keyboard navigation on reader page (#124) 2020-11-24 03:57:38 +00:00
Alex Ling e602a35b0c Merge branch 'dev' 2020-11-02 16:32:08 +00:00
Alex Ling 7792d3426e Bump version to v0.15.1 2020-11-01 09:22:05 +00:00
Alex Ling b59c8f85ad Fix scroller issues in continuous reader (#121) 2020-10-31 04:29:46 +00:00
Alex Ling 18834ac28e Set thumbnail size and mimetype 2020-10-29 04:06:44 +00:00
Alex Ling bf68e32ac8 Merge branch 'dev' 2020-10-25 07:57:26 +00:00
Alex Ling 54eb041fe4 Update README 2020-10-25 07:29:19 +00:00
Alex Ling 57d8c100f9 Bump version to v0.15.0 2020-10-25 07:22:38 +00:00
Alex Ling 56d973b99d Get progress when page loads and when post 2020-10-25 07:21:08 +00:00
Alex Ling 670e5cdf6a Better logging when optimizing DB 2020-10-25 07:09:37 +00:00
Alex Ling 1b35392f9c Remove unnecessary properties 2020-10-25 07:09:21 +00:00
Alex Ling c4e1ffe023 Trigger thumbnail generation from the admin page 2020-10-25 05:41:27 +00:00
Alex Ling 44f4959477 Finish thumbnail generation and DB optimization
(#93)
2020-10-24 04:13:11 +00:00
Alex Ling 0582b57d60 Add config options for optimization tasks 2020-10-24 03:50:26 +00:00
Alex Ling 83d96fd2a1 Add the route to serve thumbnails 2020-10-23 12:30:47 +00:00
Alex Ling 8ac89c420c Add helper methods for thumbnail generation 2020-10-23 12:30:29 +00:00
Alex Ling 968c2f4ad5 Update DB to save thumbnails 2020-10-23 12:29:20 +00:00
Alex Ling ad940f30d5 Update image_size.cr to 0.4.0 for better err msg 2020-10-23 12:21:05 +00:00
Alex Ling 308ad4e063 Only truncate visible titles to improve load time 2020-10-20 14:36:56 +00:00
Alex Ling 4d709b7eb5 Update default config in README 2020-10-18 12:53:43 +00:00
Alex Ling 5760ad924e Bump version to v0.14.0 2020-10-18 12:22:26 +00:00
Alex Ling fff171c8c9 Bump version to v0.13.0 2020-10-18 11:39:24 +00:00
Alex Ling 44ff566a1d Merge branch 'feature/paged-reader' into dev 2020-10-15 11:52:15 +00:00
Alex Ling 853f422964 Configurable read timeout (#108) 2020-10-15 11:51:04 +00:00
Alex Ling 3bb0917374 Allow /manga/<id> URL for MangaDex 2020-10-15 11:38:22 +00:00
Alex Ling a86f0d0f34 Add paged reading mode 2020-10-09 10:09:42 +00:00
Alex Ling 16a9d7fc2e Merge pull request #110 from XavierSchiller/master
[arm64] Fix Wrong libgc.so location when building Image
2020-09-27 20:07:44 +08:00
Xavier ee2b4abc85 Fix Wrong libgc.so location when building Image.
The Repo Maintainer was using the location of libgc.so from the armhf package, however, according to:
https://debian.pkgs.org/9/debian-main-arm64/libgc-dev_7.4.2-8_arm64.deb.html and
https://packages.ubuntu.com/focal/arm64/libgc-dev/filelist
it exists under /usr/lib/aarch64-linux-gnu/
2020-09-27 14:35:43 +05:30
Alex Ling a6c2799521 Bump version to v0.12.3 2020-09-22 08:55:29 +00:00
Alex Ling 2370e4d2c6 Add browserstack as a sponsor 2020-09-22 08:54:20 +00:00
Alex Ling 32b0384ea0 Clearer gulpfile 2020-09-22 08:46:53 +00:00
Alex Ling 50d4ffdb7b Use babel and polyfill.io 2020-09-22 07:40:47 +00:00
Alex Ling 96463641f9 Update progress on last page (#105) 2020-09-21 04:35:23 +00:00
Alex Ling ddbba5d596 Bump version to v0.12.2 2020-09-17 16:08:52 +00:00
Alex Ling 2a04f4531e Bound the page number in the reader route
fixes #104
2020-09-17 16:06:01 +00:00
Alex Ling a5b6fb781f Bump version to v0.12.1 2020-09-17 13:32:00 +00:00
Alex Ling 8dfdab9d73 Respect the base URL in direct download link (#103) 2020-09-17 13:29:52 +00:00
Alex Ling 3a95270dfb Don't copy unused UIKit files 2020-09-17 13:25:35 +00:00
Alex Ling 2960ca54df Move fontawesome to NPM 2020-09-17 13:20:24 +00:00
Alex Ling f5fe3c6b1c Use image_size.cr v0.2.0 2020-09-16 15:40:01 +00:00
Alex Ling a612cc15fb Bump version to v0.12.0 2020-09-12 14:35:56 +00:00
Alex Ling c9c0818069 Add inline documentation to reader.js 2020-09-12 14:32:29 +00:00
Alex Ling 2f8efc382f Clean up 2020-09-12 14:05:15 +00:00
Alex Ling a0fb1880bd Update Dockerfile [skip ci] 2020-09-12 13:59:32 +00:00
Alex Ling a408f14425 Add .dockerignore 2020-09-12 13:59:14 +00:00
Alex Ling 243b6c8927 Typo fix [skip ci] 2020-09-12 07:13:15 +00:00
Alex Ling ff3a44d017 Update ARM dockerfiles to use image_size.cr 2020-09-12 07:03:30 +00:00
Alex Ling 67ef1f7112 DRY when listing archive entries 2020-09-12 06:58:03 +00:00
Alex Ling 5d7b8a1ef9 Skip error entries in OPDS feed 2020-09-12 06:57:42 +00:00
Alex Ling a68f3eea95 Allow hyphens in username (#99) 2020-09-12 05:29:25 +00:00
Alex Ling 220fc42bf2 Add system dependencies for image_size.cr 2020-09-11 17:02:45 +00:00
Alex Ling a45e6ea3da Rewrite web reader 2020-09-11 17:00:42 +00:00
Alex Ling 88394d4636 Expose page ratios through API 2020-09-11 17:00:28 +00:00
Alex Ling ef1ab940f5 Fix GitHub tags of dependencies in the Dockerfiles
[skip ci]
2020-08-16 16:45:57 +00:00
Alex Ling 97a1c408d8 Bump version to v0.11.0 2020-08-16 12:44:02 +00:00
Alex Ling abbf77df13 Merge branch 'master' into dev 2020-08-10 14:32:53 +00:00
Alex Ling 3b4021f680 Workflow retry hack
I got random "Invalid memory access" when running `crystal build`.
This is probably a compiler or LLVM bug.
We use this temporary hack to retry until they fix it.
2020-08-10 13:14:05 +00:00
Alex Ling 68b1923cb6 Clear title ID at the end of scans
This minimizes the chance of getting an unexpected empty home page
2020-08-10 11:45:50 +00:00
Alex Ling 3cdd4b29a5 Add back to top button to all pages (#95) 2020-08-10 11:42:23 +00:00
Alex Ling af84c0f6de Fix typo 2020-08-08 17:04:42 +08:00
Alex Ling 85a65f84d0 Remove unnecessary "require" statements 2020-08-06 18:10:13 +00:00
Alex Ling 5027a911cd Respect the *_PROXY environment variables (#94) 2020-08-06 17:01:53 +00:00
Alex Ling ac63bf7599 Add sponsors [skip ci] 2020-08-06 12:49:43 +08:00
Alex Ling 30b0e0b8fb Pin down mythml and duktape versions in Dockerfile
[skip ci]
2020-08-05 12:00:49 +00:00
Alex Ling ddda058d8d Fix spec 2020-08-05 09:59:52 +00:00
Alex Ling 46db25e8e0 Fix wildcard in workflow 2020-08-05 09:50:46 +00:00
Alex Ling c07f421322 Fix CLI tool not exiting 2020-08-05 09:48:31 +00:00
Alex Ling 99a77966ad Add arm64v8 to Makefile and rename object files 2020-08-05 09:48:03 +00:00
Alex Ling d00b917575 Build the object file in Action 2020-08-04 17:24:36 +00:00
Alex Ling 4fd8334c37 Name the object file 2020-08-04 17:24:13 +00:00
Alex Ling 3aa4630558 Use Crystal 0.34.0 2020-08-04 17:23:19 +00:00
Alex Ling cde5af7066 Remove interactive prompt for easier use in docker 2020-08-04 12:57:40 +00:00
Alex Ling eb528e1726 Add the arm32v7 target to Makefile 2020-08-04 11:50:07 +00:00
Alex Ling 5e01cc38fe Fix downloaders 2020-08-04 11:36:36 +00:00
Alex Ling 9a787ccbc3 Formatting 2020-08-04 11:36:24 +00:00
Alex Ling 8a83c0df4e ARM support (#25, #78) 2020-08-04 11:00:33 +00:00
Alex Ling 87dea01917 Add ASCII banner, because we can :) 2020-08-02 17:52:52 +00:00
62 changed files with 2091 additions and 776 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
lib
+13 -4
View File
@@ -12,20 +12,29 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: crystallang/crystal:0.34.0-alpine image: crystallang/crystal:0.35.1-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 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
- name: Build - name: Build
run: make static run: make static || make static
- name: Linter - name: Linter
run: make check run: make check
- name: Run tests - name: Run tests
run: make test run: make test
- name: Upload artifact - name: Upload binary
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:
name: mango name: mango
path: mango path: mango
- name: build arm32v7 object file
run: make arm32v7 || make arm32v7
- name: build arm64v8 object file
run: make arm64v8 || make arm64v8
- name: Upload object files
uses: actions/upload-artifact@v2
with:
name: object files
path: ./*.o
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
- uses: actions/checkout@master - uses: actions/checkout@master
- name: Get release version - name: Get release version
id: get_version id: get_version
run: echo ::set-env name=RELEASE_VERSION::$(echo ${GITHUB_REF:10}) run: echo "RELEASE_VERSION=$(echo ${GITHUB_REF:10})" >> $GITHUB_ENV
- name: Publish to Dockerhub - name: Publish to Dockerhub
uses: elgohr/Publish-Docker-Github-Action@master uses: elgohr/Publish-Docker-Github-Action@master
with: with:
+3 -4
View File
@@ -1,11 +1,10 @@
FROM crystallang/crystal:0.34.0-alpine AS builder FROM crystallang/crystal:0.35.1-alpine AS builder
WORKDIR /Mango WORKDIR /Mango
COPY . . COPY . .
COPY package*.json . 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 sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static \ RUN make static || make static
&& make static
FROM library/alpine FROM library/alpine
+14
View File
@@ -0,0 +1,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 git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && 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/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
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
CMD ["./mango"]
+14
View File
@@ -0,0 +1,14 @@
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 git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && 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/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
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
CMD ["./mango"]
+8 -2
View File
@@ -12,10 +12,10 @@ setup: libs
yarn gulp dev yarn gulp dev
build: libs build: libs
crystal build src/mango.cr --release --progress crystal build src/mango.cr --release --progress --error-trace
static: uglify | libs static: uglify | libs
crystal build src/mango.cr --release --progress --static crystal build src/mango.cr --release --progress --static --error-trace
libs: libs:
shards install --production shards install --production
@@ -31,6 +31,12 @@ check:
./bin/ameba ./bin/ameba
./dev/linewidth.sh ./dev/linewidth.sh
arm32v7:
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7
arm64v8:
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='aarch64-linux-gnu' -o mango-arm64v8
install: install:
cp mango $(INSTALL_DIR)/mango cp mango $(INSTALL_DIR)/mango
+15 -4
View File
@@ -12,6 +12,7 @@ Mango is a self-hosted manga server and reader. Its features include
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar` - Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar`
- Supports nested folders in library - Supports nested folders in library
- Automatically stores reading progress - Automatically stores reading progress
- Thumbnail generation
- Built-in [MangaDex](https://mangadex.org/) downloader - 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
@@ -51,7 +52,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI ### CLI
``` ```
Mango - Manga Server and Web Reader. Version 0.10.0 Mango - Manga Server and Web Reader. Version 0.17.0
Usage: Usage:
@@ -76,22 +77,27 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
--- ---
port: 9000 port: 9000
base_url: / base_url: /
session_secret: mango-session-secret
library_path: ~/mango/library library_path: ~/mango/library
db_path: ~/mango/mango.db db_path: ~/mango/mango.db
scan_interval_minutes: 5 scan_interval_minutes: 5
thumbnail_generation_interval_hours: 24
db_optimization_interval_hours: 24
log_level: info log_level: info
upload_path: ~/mango/uploads upload_path: ~/mango/uploads
plugin_path: ~/mango/plugins
download_timeout_seconds: 30
mangadex: mangadex:
base_url: https://mangadex.org base_url: https://mangadex.org
api_url: https://mangadex.org/api api_url: https://mangadex.org/api
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: /home/alex_ling/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}'
``` ```
- `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan - `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging - `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
### Library Structure ### Library Structure
@@ -139,8 +145,13 @@ Mobile UI:
![mobile screenshot](./.github/screenshots/mobile.png) ![mobile screenshot](./.github/screenshots/mobile.png)
## Sponsors
<a href="https://casinoshunter.com/online-casinos/"><img src="https://i.imgur.com/EJb3wBo.png" width="150" height="auto"></a>
<a href="https://www.browserstack.com/open-source"><img src="https://i.imgur.com/hGJUJXD.png" width="150" height="auto"></a>
## Contributors ## Contributors
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interest in code contributions. Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interested in code contributions.
[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/0)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/0)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/1)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/1)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/2)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/2)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/3)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/3)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/4)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/4)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/5)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/5)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/6)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/6)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/7)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/7) [![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/0)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/0)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/1)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/1)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/2)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/2)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/3)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/3)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/4)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/4)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/5)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/5)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/6)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/6)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/7)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/7)
+1 -1
View File
@@ -1,5 +1,5 @@
#!/bin/sh #!/bin/sh
[ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | tee /dev/tty)" ] \ [ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | grep -v "routes/api.cr" | tee /dev/tty)" ] \
&& echo "The above lines exceed the 80 characters limit" \ && echo "The above lines exceed the 80 characters limit" \
|| exit 0 || exit 0
+45 -31
View File
@@ -1,15 +1,43 @@
const gulp = require('gulp'); const gulp = require('gulp');
const minify = require("gulp-babel-minify"); const babel = require('gulp-babel');
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');
gulp.task('copy-uikit-js', () => { // Copy libraries from node_moduels to public/js
return gulp.src('node_modules/uikit/dist/js/*.min.js') 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')); .pipe(gulp.dest('public/js'));
}); });
gulp.task('minify-js', () => { // Copy UIKit SVG icons to public/img
return gulp.src('public/js/*.js') gulp.task('copy-uikit-icons', () => {
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
.pipe(gulp.dest('public/img'));
});
// Compile less
gulp.task('less', () => {
return gulp.src('public/css/*.less')
.pipe(less())
.pipe(gulp.dest('public/css'));
});
// Transpile and minify JS files and output to dist
gulp.task('babel', () => {
return gulp.src(['public/js/*.js', '!public/js/*.min.js'])
.pipe(babel({
presets: [
['@babel/preset-env', {
targets: '>0.25%, not dead, ios>=9'
}]
],
}))
.pipe(minify({ .pipe(minify({
removeConsole: true, removeConsole: true,
builtIns: false builtIns: false
@@ -17,40 +45,26 @@ gulp.task('minify-js', () => {
.pipe(gulp.dest('dist/js')); .pipe(gulp.dest('dist/js'));
}); });
gulp.task('less', () => { // Minify CSS and output to dist
return gulp.src('public/css/*.less')
.pipe(less())
.pipe(gulp.dest('public/css'));
});
gulp.task('minify-css', () => { gulp.task('minify-css', () => {
return gulp.src('public/css/*.css') return gulp.src('public/css/*.css')
.pipe(minifyCss()) .pipe(minifyCss())
.pipe(gulp.dest('dist/css')); .pipe(gulp.dest('dist/css'));
}); });
gulp.task('copy-uikit-icons', () => { // Copy static files (includeing images) to dist
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
.pipe(gulp.dest('public/img'));
});
gulp.task('img', () => {
return gulp.src('public/img/*')
.pipe(gulp.dest('dist/img'));
});
gulp.task('copy-files', () => { gulp.task('copy-files', () => {
return gulp.src('public/*.*') return gulp.src(['public/img/*', 'public/*.*', 'public/js/*.min.js'], {
base: 'public'
})
.pipe(gulp.dest('dist')); .pipe(gulp.dest('dist'));
}); });
gulp.task('default', gulp.parallel( // Set up the public folder for development
gulp.series('copy-uikit-js', 'minify-js'), gulp.task('dev', gulp.parallel('copy-js', 'copy-uikit-icons', 'less'));
gulp.series('less', 'minify-css'),
gulp.series('copy-uikit-icons', 'img'),
'copy-files'
));
gulp.task('dev', gulp.parallel( // Set up the dist folder for deployment
'copy-uikit-js', 'less', 'copy-uikit-icons' gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));
));
// Default task
gulp.task('default', gulp.series('dev', 'deploy'));
+3
View File
@@ -6,7 +6,9 @@
"author": "Alex Ling <hkalexling@gmail.com>", "author": "Alex Ling <hkalexling@gmail.com>",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@babel/preset-env": "^7.11.5",
"gulp": "^4.0.2", "gulp": "^4.0.2",
"gulp-babel": "^8.0.0",
"gulp-babel-minify": "^0.5.1", "gulp-babel-minify": "^0.5.1",
"gulp-less": "^4.0.1", "gulp-less": "^4.0.1",
"gulp-minify-css": "^1.2.4", "gulp-minify-css": "^1.2.4",
@@ -16,6 +18,7 @@
"uglify": "gulp" "uglify": "gulp"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^5.14.0",
"uikit": "^3.5.4" "uikit": "^3.5.4"
} }
} }
+17
View File
@@ -135,3 +135,20 @@ td>.uk-dropdown {
#select-bar-controls a:hover { #select-bar-controls a:hover {
color: orange; color: orange;
} }
#main-section {
position: relative;
}
#totop-wrapper {
position: absolute;
top: 100vh;
right: 2em;
bottom: 0;
}
#totop-wrapper a {
position: fixed;
position: sticky;
top: calc(100vh - 5em);
}
+82 -32
View File
@@ -1,40 +1,90 @@
let scanning = false;
const scan = () => {
scanning = true;
$('#scan-status > div').removeAttr('hidden');
$('#scan-status > span').attr('hidden', '');
const color = $('#scan').css('color');
$('#scan').css('color', 'gray');
$.post(base_url + 'api/admin/scan', (data) => {
const ms = data.milliseconds;
const titles = data.titles;
$('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms');
$('#scan-status > span').removeAttr('hidden');
$('#scan').css('color', color);
$('#scan-status > div').attr('hidden', '');
scanning = false;
});
}
String.prototype.capitalize = function() {
return this.charAt(0).toUpperCase() + this.slice(1);
}
$(() => { $(() => {
$('li').click((e) => {
const url = $(e.currentTarget).attr('data-url');
if (url) {
$(location).attr('href', url);
}
});
const setting = loadThemeSetting(); const setting = loadThemeSetting();
$('#theme-select').val(setting.capitalize()); $('#theme-select').val(capitalize(setting));
$('#theme-select').change((e) => { $('#theme-select').change((e) => {
const newSetting = $(e.currentTarget).val().toLowerCase(); const newSetting = $(e.currentTarget).val().toLowerCase();
saveThemeSetting(newSetting); saveThemeSetting(newSetting);
setTheme(); setTheme();
}); });
getProgress();
setInterval(getProgress, 5000);
}); });
/**
* Capitalize String
*
* @function capitalize
* @param {string} str - The string to be capitalized
* @return {string} The capitalized string
*/
const capitalize = (str) => {
return str.charAt(0).toUpperCase() + str.slice(1);
};
/**
* Set an alpine.js property
*
* @function setProp
* @param {string} key - Key of the data property
* @param {*} prop - The data property
*/
const setProp = (key, prop) => {
$('#root').get(0).__x.$data[key] = prop;
};
/**
* Get an alpine.js property
*
* @function getProp
* @param {string} key - Key of the data property
* @return {*} The data property
*/
const getProp = (key) => {
return $('#root').get(0).__x.$data[key];
};
/**
* Get the thumbnail generation progress from the API
*
* @function getProgress
*/
const getProgress = () => {
$.get(`${base_url}api/admin/thumbnail_progress`)
.then(data => {
setProp('progress', data.progress);
const generating = data.progress > 0
setProp('generating', generating);
});
};
/**
* Trigger the thumbnail generation
*
* @function generateThumbnails
*/
const generateThumbnails = () => {
setProp('generating', true);
setProp('progress', 0.0);
$.post(`${base_url}api/admin/generate_thumbnails`)
.then(getProgress);
};
/**
* Trigger the scan
*
* @function scan
*/
const scan = () => {
setProp('scanning', true);
setProp('scanMs', -1);
setProp('scanTitles', 0);
$.post(`${base_url}api/admin/scan`)
.then(data => {
setProp('scanMs', data.milliseconds);
setProp('scanTitles', data.titles);
})
.always(() => {
setProp('scanning', false);
});
}
+147
View File
@@ -0,0 +1,147 @@
/**
* --- Alpine helper functions
*/
/**
* Set an alpine.js property
*
* @function setProp
* @param {string} key - Key of the data property
* @param {*} prop - The data property
* @param {string} selector - The jQuery selector to the root element
*/
const setProp = (key, prop, selector = '#root') => {
$(selector).get(0).__x.$data[key] = prop;
};
/**
* Get an alpine.js property
*
* @function getProp
* @param {string} key - Key of the data property
* @param {string} selector - The jQuery selector to the root element
* @return {*} The data property
*/
const getProp = (key, selector = '#root') => {
return $(selector).get(0).__x.$data[key];
};
/**
* --- Theme related functions
* Note: In the comments below we treat "theme" and "theme setting"
* differently. A theme can have only two values, either "dark" or
* "light", while a theme setting can have the third value "system".
*/
/**
* Check if the system setting prefers dark theme.
* from https://flaviocopes.com/javascript-detect-dark-mode/
*
* @function preferDarkMode
* @return {bool}
*/
const preferDarkMode = () => {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
};
/**
* Check whether a given string represents a valid theme setting
*
* @function validThemeSetting
* @param {string} theme - The string representing the theme setting
* @return {bool}
*/
const validThemeSetting = (theme) => {
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
};
/**
* Load theme setting from local storage, or use 'light'
*
* @function loadThemeSetting
* @return {string} A theme setting ('dark', 'light', or 'system')
*/
const loadThemeSetting = () => {
let str = localStorage.getItem('theme');
if (!str || !validThemeSetting(str)) str = 'light';
return str;
};
/**
* Load the current theme (not theme setting)
*
* @function loadTheme
* @return {string} The current theme to use ('dark' or 'light')
*/
const loadTheme = () => {
let setting = loadThemeSetting();
if (setting === 'system') {
setting = preferDarkMode() ? 'dark' : 'light';
}
return setting;
};
/**
* Save a theme setting
*
* @function saveThemeSetting
* @param {string} setting - A theme setting
*/
const saveThemeSetting = setting => {
if (!validThemeSetting(setting)) setting = 'light';
localStorage.setItem('theme', setting);
};
/**
* Toggle the current theme. When the current theme setting is 'system', it
* will be changed to either 'light' or 'dark'
*
* @function toggleTheme
*/
const toggleTheme = () => {
const theme = loadTheme();
const newTheme = theme === 'dark' ? 'light' : 'dark';
saveThemeSetting(newTheme);
setTheme(newTheme);
};
/**
* Apply a theme, or load a theme and then apply it
*
* @function setTheme
* @param {string?} theme - (Optional) The theme to apply. When omitted, use
* `loadTheme` to get a theme and apply it.
*/
const setTheme = (theme) => {
if (!theme) theme = loadTheme();
if (theme === 'dark') {
$('html').css('background', 'rgb(20, 20, 20)');
$('body').addClass('uk-light');
$('.uk-card').addClass('uk-card-secondary');
$('.uk-card').removeClass('uk-card-default');
$('.ui-widget-content').addClass('dark');
} else {
$('html').css('background', '');
$('body').removeClass('uk-light');
$('.uk-card').removeClass('uk-card-secondary');
$('.uk-card').addClass('uk-card-default');
$('.ui-widget-content').removeClass('dark');
}
};
// do it before document is ready to prevent the initial flash of white on
// most pages
setTheme();
$(() => {
// hack for the reader page
setTheme();
// on system dark mode setting change
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', event => {
if (loadThemeSetting() === 'system')
setTheme(event.matches ? 'dark' : 'light');
});
}
});
+13 -4
View File
@@ -1,5 +1,10 @@
const truncate = () => { /**
$('.uk-card-title').each((i, e) => { * Truncate a .uk-card-title element
*
* @function truncate
* @param {object} e - The title element to truncate
*/
const truncate = (e) => {
$(e).dotdotdot({ $(e).dotdotdot({
truncate: 'letter', truncate: 'letter',
watch: true, watch: true,
@@ -11,7 +16,11 @@ const truncate = () => {
} }
} }
}); });
});
}; };
truncate(); $('.uk-card-title').each((i, e) => {
// Truncate the title when it first enters the view
$(e).one('inview', () => {
truncate(e);
});
});
+84 -105
View File
@@ -1,28 +1,42 @@
$(() => { /**
$('input.uk-checkbox').each((i, e) => { * Get the current queue and update the view
$(e).change(() => { *
loadConfig(); * @function load
*/
const load = () => {
try {
setProp('loading', true);
} catch {}
$.ajax({
type: 'GET',
url: base_url + 'api/admin/mangadex/queue',
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
return;
}
setProp('jobs', data.jobs);
setProp('paused', data.paused);
})
.fail((jqXHR, status) => {
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
setProp('loading', false);
}); });
});
loadConfig();
load();
const intervalMS = 5000;
setTimeout(() => {
setInterval(() => {
if (globalConfig.autoRefresh !== true) return;
load();
}, intervalMS);
}, intervalMS);
});
var globalConfig = {};
var loading = false;
const loadConfig = () => {
globalConfig.autoRefresh = $('#auto-refresh').prop('checked');
}; };
const remove = (id) => {
var url = base_url + 'api/admin/mangadex/queue/delete'; /**
* Perform an action on either a specific job or the entire queue
*
* @function jobAction
* @param {string} action - The action to perform. Should be either 'delete' or 'retry'
* @param {string?} id - (Optional) A job ID. When omitted, apply the action to the queue
*/
const jobAction = (action, id) => {
let url = `${base_url}api/admin/mangadex/queue/${action}`;
if (id !== undefined) if (id !== undefined)
url += '?' + $.param({ url += '?' + $.param({
id: id id: id
@@ -35,42 +49,24 @@ const remove = (id) => {
}) })
.done(data => { .done(data => {
if (!data.success && data.error) { if (!data.success && data.error) {
alert('danger', `Failed to remove job from download queue. Error: ${data.error}`); alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
return; return;
} }
load(); load();
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert('danger', `Failed to remove job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
const refresh = (id) => {
var url = base_url + 'api/admin/mangadex/queue/retry';
if (id !== undefined)
url += '?' + $.param({
id: id
});
console.log(url);
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to restart download job. Error: ${data.error}`);
return;
}
load();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to restart download job. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}); });
}; };
/**
* Pause/resume the download
*
* @function toggle
*/
const toggle = () => { const toggle = () => {
$('#pause-resume-btn').attr('disabled', ''); setProp('toggling', true);
const paused = $('#pause-resume-btn').text() === 'Resume download'; const action = getProp('paused') ? 'resume' : 'pause';
const action = paused ? 'resume' : 'pause';
const url = `${base_url}api/admin/mangadex/queue/${action}`; const url = `${base_url}api/admin/mangadex/queue/${action}`;
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
@@ -82,64 +78,47 @@ const toggle = () => {
}) })
.always(() => { .always(() => {
load(); load();
$('#pause-resume-btn').removeAttr('disabled'); setProp('toggling', false);
}); });
}; };
const load = () => {
if (loading) return; /**
loading = true; * Get the uk-label class name for a given job status
console.log('fetching'); *
$.ajax({ * @function statusClass
type: 'GET', * @param {string} status - The job status
url: base_url + 'api/admin/mangadex/queue', * @return {string} The class name string
dataType: 'json' */
}) const statusClass = status => {
.done(data => { let cls = 'label ';
if (!data.success && data.error) { switch (status) {
alert('danger', `Failed to fetch download queue. Error: ${data.error}`); case 'Pending':
return;
}
console.log(data);
const btnText = data.paused ? "Resume download" : "Pause download";
$('#pause-resume-btn').text(btnText);
$('#pause-resume-btn').removeAttr('hidden');
const rows = data.jobs.map(obj => {
var cls = 'label ';
if (obj.status === 'Pending')
cls += 'label-pending'; cls += 'label-pending';
if (obj.status === 'Completed') break;
case 'Completed':
cls += 'label-success'; cls += 'label-success';
if (obj.status === 'Error') break;
case 'Error':
cls += 'label-danger'; cls += 'label-danger';
if (obj.status === 'MissingPages') break;
case 'MissingPages':
cls += 'label-warning'; cls += 'label-warning';
break;
const info = obj.status_message.length > 0 ? '<span uk-icon="info"></span>' : ''; }
const statusSpan = `<span class="${cls}">${obj.status} ${info}</span>`; return cls;
const dropdown = obj.status_message.length > 0 ? `<div uk-dropdown>${obj.status_message}</div>` : '';
const retryBtn = obj.status_message.length > 0 ? `<a onclick="refresh('${obj.id}')" uk-icon="refresh"></a>` : '';
return `<tr id="chapter-${obj.id}">
<td>${obj.plugin_id ? obj.title : `<a href="${baseURL}/chapter/${obj.id}">${obj.title}</a>`}</td>
<td>${obj.plugin_id ? obj.manga_title : `<a href="${baseURL}/manga/${obj.manga_id}">${obj.manga_title}</a>`}</td>
<td>${obj.success_count}/${obj.pages}</td>
<td>${moment(obj.time).fromNow()}</td>
<td>${statusSpan} ${dropdown}</td>
<td>${obj.plugin_id || ""}</td>
<td>
<a onclick="remove('${obj.id}')" uk-icon="trash"></a>
${retryBtn}
</td>
</tr>`;
});
const tbody = `<tbody>${rows.join('')}</tbody>`;
$('tbody').remove();
$('table').append(tbody);
})
.fail((jqXHR, status) => {
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
loading = false;
});
}; };
$(() => {
const ws = new WebSocket(`ws://${location.host}/api/admin/mangadex/queue`);
ws.onmessage = event => {
const data = JSON.parse(event.data);
setProp('jobs', data.jobs);
setProp('paused', data.paused);
};
ws.onerror = err => {
alert('danger', `Socket connection failed. Error: ${err}`);
};
ws.onclose = err => {
alert('danger', 'Socket connection failed');
};
});
+1 -1
View File
@@ -95,7 +95,7 @@ const search = () => {
try { try {
const path = new URL(input).pathname; const path = new URL(input).pathname;
const match = /\/title\/([0-9]+)/.exec(path); const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
int_id = parseInt(match[1]); int_id = parseInt(match[1]);
} catch (e) { } catch (e) {
int_id = parseInt(input); int_id = parseInt(input);
-5
View File
File diff suppressed because one or more lines are too long
+6 -7
View File
@@ -33,14 +33,13 @@ const search = () => {
if (searching) if (searching)
return; return;
const query = $('#search-input').val(); const query = $.param({
$.ajax({ query: $('#search-input').val(),
type: 'POST',
url: base_url + 'api/admin/plugin/list',
data: JSON.stringify({
query: query,
plugin: pid plugin: pid
}), });
$.ajax({
type: 'GET',
url: `${base_url}api/admin/plugin/list?${query}`,
contentType: "application/json", contentType: "application/json",
dataType: 'json' dataType: 'json'
}) })
+273 -64
View File
@@ -1,64 +1,149 @@
$(function() { let lastSavedPage = page;
function bind() { let items = [];
var controller = new ScrollMagic.Controller(); let longPages = false;
// replace history on scroll $(() => {
$('img').each(function(idx) { getPages();
var scene = new ScrollMagic.Scene({
triggerElement: $(this).get(), $('#page-select').change(() => {
triggerHook: 'onEnter', const p = parseInt($('#page-select').val());
reverse: true toPage(p);
}) });
.addTo(controller)
.on('enter', function(event) { $('#mode-select').change(() => {
current = $(event.target.triggerElement()).attr('id'); const mode = $('#mode-select').val();
replaceHistory(current); const curIdx = parseInt($('#page-select').val());
})
.on('leave', function(event) { updateMode(mode, curIdx);
var prev = $(event.target.triggerElement()).prev();
current = $(prev).attr('id');
replaceHistory(current);
}); });
}); });
// poor man's infinite scroll $(window).resize(() => {
var scene = new ScrollMagic.Scene({ const mode = getProp('mode');
triggerElement: $('.next-url').get(), if (mode === 'continuous') return;
triggerHook: 'onEnter',
offset: -500 const wideScreen = $(window).width() > $(window).height();
}) const propMode = wideScreen ? 'height' : 'width';
.addTo(controller) setProp('mode', propMode);
.on('enter', function() {
var nextURL = $('.next-url').attr('href');
$('.next-url').remove();
if (!nextURL) {
console.log('No .next-url found. Reached end of page');
var lastURL = $('img').last().attr('id');
// load the reader URL for the last page to update reading progrss to 100%
$.get(lastURL);
$('#next-btn').removeAttr('hidden');
return;
}
$('#hidden').load(encodeURI(nextURL) + ' .uk-container', function(res, status, xhr) {
if (status === 'error') console.log(xhr.statusText);
if (status === 'success') {
console.log(nextURL + ' loaded');
// new page loaded to #hidden, we now append it
$('.uk-section > .uk-container').append($('#hidden .uk-container').children());
$('#hidden').empty();
bind();
}
}); });
/**
* Update the reader mode
*
* @function updateMode
* @param {string} mode - The mode. Can be one of the followings:
* {'continuous', 'paged', 'height', 'width'}
* @param {number} targetPage - The one-based index of the target page
*/
const updateMode = (mode, targetPage) => {
localStorage.setItem('mode', mode);
// The mode to be put into the `mode` prop. It can't be `screen`
let propMode = mode;
if (mode === 'paged') {
const wideScreen = $(window).width() > $(window).height();
propMode = wideScreen ? 'height' : 'width';
}
setProp('mode', propMode);
if (mode === 'continuous') {
waitForPage(items.length, () => {
setupScroller();
}); });
} }
bind(); waitForPage(targetPage, () => {
setTimeout(() => {
toPage(targetPage);
}, 100);
}); });
$('#page-select').change(function() { };
jumpTo(parseInt($('#page-select').val()));
/**
* Get dimension of the pages in the entry from the API and update the view
*/
const getPages = () => {
$.get(`${base_url}api/dimensions/${tid}/${eid}`)
.then(data => {
if (!data.success && data.error)
throw new Error(resp.error);
const dimensions = data.dimensions;
items = dimensions.map((d, i) => {
return {
id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width,
height: d.height
};
}); });
function showControl(idx) { const avgRatio = items.reduce((acc, cur) => {
return acc + cur.height / cur.width
}, 0) / items.length;
console.log(avgRatio);
longPages = avgRatio > 2;
setProp('items', items);
setProp('loading', false);
const storedMode = localStorage.getItem('mode') || 'continuous';
setProp('mode', storedMode);
updateMode(storedMode, page);
$('#mode-select').val(storedMode);
})
.catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`;
console.error(e);
setProp('alertClass', 'uk-alert-danger');
setProp('msg', errMsg);
})
};
/**
* Jump to a specific page
*
* @function toPage
* @param {number} idx - One-based index of the page
*/
const toPage = (idx) => {
const mode = getProp('mode');
if (mode === 'continuous') {
$(`#${idx}`).get(0).scrollIntoView(true);
} else {
if (idx >= 1 && idx <= items.length) {
setProp('curItem', items[idx - 1]);
}
}
replaceHistory(idx);
UIkit.modal($('#modal-sections')).hide();
};
/**
* Check if a page exists every 100ms. If so, invoke the callback function.
*
* @function waitForPage
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback function
*/
const waitForPage = (idx, cb) => {
if ($(`#${idx}`).length > 0) return cb();
setTimeout(() => {
waitForPage(idx, cb)
}, 100);
};
/**
* Show the control modal
*
* @function showControl
* @param {string} idx - One-based index of the current page
*/
const showControl = (idx) => {
const pageCount = $('#page-select > option').length; const pageCount = $('#page-select > option').length;
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`; const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
$('#progress-label').text(progressText); $('#progress-label').text(progressText);
@@ -66,19 +151,143 @@ function showControl(idx) {
UIkit.modal($('#modal-sections')).show(); UIkit.modal($('#modal-sections')).show();
} }
function jumpTo(page) { /**
var ary = window.location.pathname.split('/'); * Redirect to a URL
ary[ary.length - 1] = page; *
ary.shift(); // remove leading `/` * @function redirect
ary.unshift(window.location.origin); * @param {string} url - The target URL
window.location.replace(ary.join('/')); */
} const redirect = (url) => {
function replaceHistory(url) {
history.replaceState(null, "", url);
console.log('reading ' + url);
}
function redirect(url) {
window.location.replace(url); window.location.replace(url);
} }
/**
* Replace the address bar history and save th ereading progress if necessary
*
* @function replaceHistory
* @param {number} idx - One-based index of the current page
*/
const replaceHistory = (idx) => {
const ary = window.location.pathname.split('/');
ary[ary.length - 1] = idx;
ary.shift(); // remove leading `/`
ary.unshift(window.location.origin);
const url = ary.join('/');
saveProgress(idx);
history.replaceState(null, "", url);
}
/**
* Set up the scroll handler that calls `replaceHistory` when an image
* enters the view port
*
* @function setupScroller
*/
const setupScroller = () => {
const mode = getProp('mode');
if (mode !== 'continuous') return;
$('#root img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
setProp('curItem', getProp('items')[current - 1]);
replaceHistory(current);
}
});
});
};
/**
* Update the backend reading progress if:
* 1) the current page is more than five pages away from the last
* saved page, or
* 2) the average height/width ratio of the pages is over 2, or
* 3) the current page is the first page, or
* 4) the current page is the last page
*
* @function saveProgress
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback
*/
const saveProgress = (idx, cb) => {
idx = parseInt(idx);
if (Math.abs(idx - lastSavedPage) >= 5 ||
longPages ||
idx === 1 || idx === items.length
) {
lastSavedPage = idx;
console.log('saving progress', idx);
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
$.ajax({
method: 'PUT',
url: url,
dataType: 'json'
})
.done(data => {
if (data.error)
alert('danger', data.error);
if (cb) cb();
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}
};
/**
* Mark progress to 100% and redirect to the next entry
* Used as the onclick handler for the "Next Entry" button
*
* @function nextEntry
* @param {string} nextUrl - URL of the next entry
*/
const nextEntry = (nextUrl) => {
saveProgress(items.length, () => {
redirect(nextUrl);
});
};
/**
* Show the next or the previous page
*
* @function flipPage
* @param {bool} isNext - Whether we are going to the next page
*/
const flipPage = (isNext) => {
const curItem = getProp('curItem');
const idx = parseInt(curItem.id);
const delta = isNext ? 1 : -1;
const newIdx = idx + delta;
toPage(newIdx);
if (isNext)
setProp('flipAnimation', 'right');
else
setProp('flipAnimation', 'left');
setTimeout(() => {
setProp('flipAnimation', null);
}, 500);
replaceHistory(newIdx);
saveProgress(newIdx);
};
/**
* Handle the global keydown events
*
* @function keyHandler
* @param {event} event - The $event object
*/
const keyHandler = (event) => {
const mode = getProp('mode');
if (mode === 'continuous') return;
if (event.key === 'ArrowLeft' || event.key === 'k')
flipPage(false);
if (event.key === 'ArrowRight' || event.key === 'j')
flipPage(true);
};
-5
View File
File diff suppressed because one or more lines are too long
-72
View File
@@ -1,72 +0,0 @@
// https://flaviocopes.com/javascript-detect-dark-mode/
const preferDarkMode = () => {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
};
const validThemeSetting = (theme) => {
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
};
// dark / light / system
const loadThemeSetting = () => {
let str = localStorage.getItem('theme');
if (!str || !validThemeSetting(str)) str = 'light';
return str;
};
// dark / light
const loadTheme = () => {
let setting = loadThemeSetting();
if (setting === 'system') {
setting = preferDarkMode() ? 'dark' : 'light';
}
return setting;
};
const saveThemeSetting = setting => {
if (!validThemeSetting(setting)) setting = 'light';
localStorage.setItem('theme', setting);
};
// when toggled, Auto will be changed to light or dark
const toggleTheme = () => {
const theme = loadTheme();
const newTheme = theme === 'dark' ? 'light' : 'dark';
saveThemeSetting(newTheme);
setTheme(newTheme);
};
const setTheme = (theme) => {
if (!theme) theme = loadTheme();
if (theme === 'dark') {
$('html').css('background', 'rgb(20, 20, 20)');
$('body').addClass('uk-light');
$('.uk-card').addClass('uk-card-secondary');
$('.uk-card').removeClass('uk-card-default');
$('.ui-widget-content').addClass('dark');
} else {
$('html').css('background', '');
$('body').removeClass('uk-light');
$('.uk-card').removeClass('uk-card-secondary');
$('.uk-card').addClass('uk-card-default');
$('.ui-widget-content').removeClass('dark');
}
};
// do it before document is ready to prevent the initial flash of white on
// most pages
setTheme();
$(() => {
// hack for the reader page
setTheme();
// on system dark mode setting change
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', event => {
if (loadThemeSetting() === 'system')
setTheme(event.matches ? 'dark' : 'light');
});
}
});
+19 -9
View File
@@ -55,7 +55,7 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`); $('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
$('#modal-download-btn').attr('href', `/opds/download/${titleID}/${entryID}`); $('#modal-download-btn').attr('href', `${base_url}api/download/${titleID}/${entryID}`);
UIkit.modal($('#modal')).show(); UIkit.modal($('#modal')).show();
} }
@@ -63,17 +63,26 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
const updateProgress = (tid, eid, page) => { const updateProgress = (tid, eid, page) => {
let url = `${base_url}api/progress/${tid}/${page}` let url = `${base_url}api/progress/${tid}/${page}`
const query = $.param({ const query = $.param({
entry: eid eid: eid
}); });
if (eid) if (eid)
url += `?${query}`; url += `?${query}`;
$.post(url, (data) => {
$.ajax({
method: 'PUT',
url: url,
dataType: 'json'
})
.done(data => {
if (data.success) { if (data.success) {
location.reload(); location.reload();
} else { } else {
error = data.error; error = data.error;
alert('danger', error); alert('danger', error);
} }
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}); });
}; };
@@ -89,14 +98,14 @@ const renameSubmit = (name, eid) => {
} }
const query = $.param({ const query = $.param({
entry: eid eid: eid
}); });
let url = `${base_url}api/admin/display_name/${titleId}/${name}`; let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
if (eid) if (eid)
url += `?${query}`; url += `?${query}`;
$.ajax({ $.ajax({
type: 'POST', type: 'PUT',
url: url, url: url,
contentType: "application/json", contentType: "application/json",
dataType: 'json' dataType: 'json'
@@ -131,6 +140,7 @@ const edit = (eid) => {
const displayNameField = $('#display-name-field'); const displayNameField = $('#display-name-field');
displayNameField.attr('value', displayName); displayNameField.attr('value', displayName);
console.log(displayNameField);
displayNameField.keyup(event => { displayNameField.keyup(event => {
if (event.keyCode === 13) { if (event.keyCode === 13) {
renameSubmit(displayNameField.val(), eid); renameSubmit(displayNameField.val(), eid);
@@ -150,10 +160,10 @@ const setupUpload = (eid) => {
const bar = $('#upload-progress').get(0); const bar = $('#upload-progress').get(0);
const titleId = upload.attr('data-title-id'); const titleId = upload.attr('data-title-id');
const queryObj = { const queryObj = {
title: titleId tid: titleId
}; };
if (eid) if (eid)
queryObj['entry'] = eid; queryObj['eid'] = eid;
const query = $.param(queryObj); const query = $.param(queryObj);
const url = `${base_url}api/admin/upload/cover?${query}`; const url = `${base_url}api/admin/upload/cover?${query}`;
console.log(url); console.log(url);
@@ -218,9 +228,9 @@ const selectedIDs = () => {
const bulkProgress = (action, el) => { const bulkProgress = (action, el) => {
const tid = $(el).attr('data-id'); const tid = $(el).attr('data-id');
const ids = selectedIDs(); const ids = selectedIDs();
const url = `${base_url}api/bulk-progress/${action}/${tid}`; const url = `${base_url}api/bulk_progress/${action}/${tid}`;
$.ajax({ $.ajax({
type: 'POST', type: 'PUT',
url: url, url: url,
contentType: "application/json", contentType: "application/json",
dataType: 'json', dataType: 'json',
+14 -9
View File
@@ -1,11 +1,16 @@
function remove(username) { const remove = (username) => {
$.post(base_url + 'api/admin/user/delete/' + username, function(data) { $.ajax({
if (data.success) { url: `${base_url}api/admin/user/delete/${username}`,
type: 'DELETE',
dataType: 'json'
})
.done(data => {
if (data.success)
location.reload(); location.reload();
} else
else { alert('danger', data.error);
error = data.error; })
alert('danger', error); .fail((jqXHR, status) => {
} alert('danger', `Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}); });
} };
+32 -16
View File
@@ -1,54 +1,70 @@
version: 1.0 version: 2.0
shards: shards:
ameba: ameba:
github: crystal-ameba/ameba git: https://github.com/crystal-ameba/ameba.git
version: 0.12.1 version: 0.12.1
archive: archive:
github: hkalexling/archive.cr git: https://github.com/hkalexling/archive.cr.git
version: 0.4.0 version: 0.4.0
baked_file_system: baked_file_system:
github: schovi/baked_file_system git: https://github.com/schovi/baked_file_system.git
version: 0.9.8 version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
clim: clim:
github: at-grandpa/clim git: https://github.com/at-grandpa/clim.git
version: 0.12.0 version: 0.12.0
db: db:
github: crystal-lang/crystal-db git: https://github.com/crystal-lang/crystal-db.git
version: 0.9.0 version: 0.9.0
duktape: duktape:
github: jessedoyle/duktape.cr git: https://github.com/jessedoyle/duktape.cr.git
version: 0.20.0 version: 0.20.0
exception_page: exception_page:
github: crystal-loot/exception_page git: https://github.com/crystal-loot/exception_page.git
version: 0.1.4 version: 0.1.4
http_proxy:
git: https://github.com/mamantoha/http_proxy.git
version: 0.7.1
image_size:
git: https://github.com/hkalexling/image_size.cr.git
version: 0.4.0
kemal: kemal:
github: kemalcr/kemal git: https://github.com/kemalcr/kemal.git
version: 0.26.1 version: 0.27.0
kemal-session: kemal-session:
github: kemalcr/kemal-session git: https://github.com/kemalcr/kemal-session.git
version: 0.12.1 version: 0.12.1
kilt: kilt:
github: jeromegn/kilt git: https://github.com/jeromegn/kilt.git
version: 0.4.0 version: 0.4.0
koa:
git: https://github.com/hkalexling/koa.git
version: 0.5.0
myhtml: myhtml:
github: kostya/myhtml git: https://github.com/kostya/myhtml.git
version: 1.5.1 version: 1.5.1
open_api:
git: https://github.com/jreinert/open_api.cr.git
version: 1.2.1+git.commit.95e4df2ca10b1fe88b8b35c62a18b06a10267b6c
radix: radix:
github: luislavena/radix git: https://github.com/luislavena/radix.git
version: 0.3.9 version: 0.3.9
sqlite3: sqlite3:
github: crystal-lang/crystal-sqlite3 git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.16.0 version: 0.16.0
+9 -2
View File
@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.10.0 version: 0.17.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.34.0 crystal: 0.35.1
license: MIT license: MIT
@@ -21,6 +21,7 @@ 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:
@@ -32,3 +33,9 @@ dependencies:
version: ~> 0.20.0 version: ~> 0.20.0
myhtml: myhtml:
github: kostya/myhtml github: kostya/myhtml
http_proxy:
github: mamantoha/http_proxy
image_size:
github: hkalexling/image_size.cr
koa:
github: hkalexling/koa
+1
View File
@@ -2,6 +2,7 @@ require "spec"
require "../src/queue" require "../src/queue"
require "../src/server" require "../src/server"
require "../src/config" require "../src/config"
require "../src/main_fiber"
class State class State
@@hash = {} of String => String @@hash = {} of String => String
+11 -11
View File
@@ -1,13 +1,13 @@
require "zip" require "compress/zip"
require "archive" require "archive"
# A unified class to handle all supported archive formats. It uses the ::Zip # A unified class to handle all supported archive formats. It uses the
# module in crystal standard library if the target file is a zip archive. # Compress::Zip module in crystal standard library if the target file is
# Otherwise it uses `archive.cr`. # a zip archive. Otherwise it uses `archive.cr`.
class ArchiveFile class ArchiveFile
def initialize(@filename : String) def initialize(@filename : String)
if [".cbz", ".zip"].includes? File.extname filename if [".cbz", ".zip"].includes? File.extname filename
@archive_file = Zip::File.new filename @archive_file = Compress::Zip::File.new filename
else else
@archive_file = Archive::File.new filename @archive_file = Archive::File.new filename
end end
@@ -20,16 +20,16 @@ class ArchiveFile
end end
def close def close
if @archive_file.is_a? Zip::File if @archive_file.is_a? Compress::Zip::File
@archive_file.as(Zip::File).close @archive_file.as(Compress::Zip::File).close
end end
end end
# Lists all file entries # Lists all file entries
def entries def entries
ary = [] of Zip::File::Entry | Archive::Entry ary = [] of Compress::Zip::File::Entry | Archive::Entry
@archive_file.entries.map do |e| @archive_file.entries.map do |e|
if (e.is_a? Zip::File::Entry && e.file?) || if (e.is_a? Compress::Zip::File::Entry && e.file?) ||
(e.is_a? Archive::Entry && e.info.file?) (e.is_a? Archive::Entry && e.info.file?)
ary.push e ary.push e
end end
@@ -37,8 +37,8 @@ class ArchiveFile
ary ary
end end
def read_entry(e : Zip::File::Entry | Archive::Entry) : Bytes? def read_entry(e : Compress::Zip::File::Entry | Archive::Entry) : Bytes?
if e.is_a? Zip::File::Entry if e.is_a? Compress::Zip::File::Entry
data = nil data = nil
e.open do |io| e.open do |io|
slice = Bytes.new e.uncompressed_size slice = Bytes.new e.uncompressed_size
+5 -7
View File
@@ -11,13 +11,15 @@ class Config
property library_path : String = File.expand_path "~/mango/library", property library_path : String = File.expand_path "~/mango/library",
home: true home: true
property db_path : String = File.expand_path "~/mango/mango.db", home: true property db_path : String = File.expand_path "~/mango/mango.db", home: true
@[YAML::Field(key: "scan_interval_minutes")] property scan_interval_minutes : Int32 = 5
property scan_interval : Int32 = 5 property thumbnail_generation_interval_hours : Int32 = 24
property db_optimization_interval_hours : Int32 = 24
property log_level : String = "info" property log_level : String = "info"
property upload_path : String = File.expand_path "~/mango/uploads", property upload_path : String = File.expand_path "~/mango/uploads",
home: true home: true
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 mangadex = Hash(String, String | Int32).new property mangadex = Hash(String, String | Int32).new
@[YAML::Field(ignore: true)] @[YAML::Field(ignore: true)]
@@ -53,11 +55,7 @@ class Config
return config return config
end end
puts "The config file #{cfg_path} does not exist. " \ puts "The config file #{cfg_path} does not exist. " \
" Do you want mango to dump the default config there? [Y/n]" "Dumping the default config there."
input = gets
if input && input.downcase == "n"
abort "Aborting..."
end
default = self.allocate default = self.allocate
default.path = path default.path = path
default.fill_defaults default.fill_defaults
+1 -1
View File
@@ -23,7 +23,7 @@ class StaticHandler < Kemal::Handler
slice = Bytes.new file.size slice = Bytes.new file.size
file.read slice file.read slice
return send_file env, slice, file.mime_type return send_file env, slice, MIME.from_filename file.path
end end
call_next env call_next env
end end
+64 -8
View File
@@ -1,3 +1,5 @@
require "image_size"
class Entry class Entry
property zip_path : String, book : Title, title : String, property zip_path : String, book : Title, title : String,
size : String, pages : Int32, id : String, encoded_path : String, size : String, pages : Int32, id : String, encoded_path : String,
@@ -45,8 +47,7 @@ class Entry
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"] %}
"encoded_path", "encoded_title"] %}
json.field {{str}}, @{{str.id}} json.field {{str}}, @{{str.id}}
{% end %} {% end %}
json.field "title_id", @book.id json.field "title_id", @book.id
@@ -67,7 +68,7 @@ class Entry
def cover_url def cover_url
return "#{Config.current.base_url}img/icon.png" if @err_msg return "#{Config.current.base_url}img/icon.png" if @err_msg
url = "#{Config.current.base_url}api/page/#{@book.id}/#{@id}/1" url = "#{Config.current.base_url}api/cover/#{@book.id}/#{@id}"
TitleInfo.new @book.dir do |info| TitleInfo.new @book.dir do |info|
info_url = info.entry_cover_url[@title]? info_url = info.entry_cover_url[@title]?
unless info_url.nil? || info_url.empty? unless info_url.nil? || info_url.empty?
@@ -77,11 +78,9 @@ class Entry
url url
end end
def read_page(page_num) private def sorted_archive_entries
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
ArchiveFile.open @zip_path do |file| ArchiveFile.open @zip_path do |file|
page = file.entries entries = file.entries
.select { |e| .select { |e|
SUPPORTED_IMG_TYPES.includes? \ SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename MIME.from_filename? e.filename
@@ -89,7 +88,15 @@ class Entry
.sort { |a, b| .sort { |a, b|
compare_numerically a.filename, b.filename compare_numerically a.filename, b.filename
} }
.[page_num - 1] yield file, entries
end
end
def read_page(page_num)
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
sorted_archive_entries do |file, entries|
page = entries[page_num - 1]
data = file.read_entry page data = file.read_entry page
if data if data
img = Image.new data, MIME.from_filename(page.filename), page.filename, img = Image.new data, MIME.from_filename(page.filename), page.filename,
@@ -99,6 +106,26 @@ class Entry
img img
end end
def page_dimensions
sizes = [] of Hash(String, Int32)
sorted_archive_entries do |file, entries|
entries.each_with_index do |e, i|
begin
data = file.read_entry(e).not_nil!
size = ImageSize.get data
sizes << {
"width" => size.width,
"height" => size.height,
}
rescue e
Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
sizes << {"width" => 1000_i32, "height" => 1000_i32}
end
end
end
sizes
end
def next_entry(username) def next_entry(username)
entries = @book.sorted_entries username entries = @book.sorted_entries username
idx = entries.index self idx = entries.index self
@@ -179,4 +206,33 @@ class Entry
def started?(username) def started?(username)
load_progress(username) > 0 load_progress(username) > 0
end end
def generate_thumbnail : Image?
return if @err_msg
img = read_page(1).not_nil!
begin
size = ImageSize.get img.data
if size.height > size.width
thumbnail = ImageSize.resize img.data, width: 200
else
thumbnail = ImageSize.resize img.data, height: 300
end
img.data = thumbnail
img.size = thumbnail.size
unless img.mime == "image/webp"
# image_size.cr resizes non-webp images to jpg
img.mime = "image/jpeg"
end
Storage.default.save_thumbnail @id, img
rescue e
Logger.warn "Failed to generate thumbnail for file #{@zip_path}. #{e}"
end
img
end
def get_thumbnail : Image?
Storage.default.get_thumbnail @id
end
end end
+79 -5
View File
@@ -1,5 +1,5 @@
class Library class Library
property dir : String, title_ids : Array(String), scan_interval : Int32, property dir : String, title_ids : Array(String),
title_hash : Hash(String, Title) title_hash : Hash(String, Title)
use_default use_default
@@ -8,20 +8,48 @@ class Library
register_mime_types register_mime_types
@dir = Config.current.library_path @dir = Config.current.library_path
@scan_interval = Config.current.scan_interval
# explicitly initialize @titles to bypass the compiler check. it will # explicitly initialize @titles to bypass the compiler check. it will
# be filled with actual Titles in the `scan` call below # be filled with actual Titles in the `scan` call below
@title_ids = [] of String @title_ids = [] of String
@title_hash = {} of String => Title @title_hash = {} of String => Title
return scan if @scan_interval < 1 @entries_count = 0
@thumbnails_count = 0
scan_interval = Config.current.scan_interval_minutes
if scan_interval < 1
scan
else
spawn do spawn do
loop do loop do
start = Time.local start = Time.local
scan scan
ms = (Time.local - start).total_milliseconds ms = (Time.local - start).total_milliseconds
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms" Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
sleep @scan_interval * 60 sleep scan_interval.minutes
end
end
end
thumbnail_interval = Config.current.thumbnail_generation_interval_hours
unless thumbnail_interval < 1
spawn do
loop do
# Wait for scan to complete (in most cases)
sleep 1.minutes
generate_thumbnails
sleep thumbnail_interval.hours
end
end
end
db_interval = Config.current.db_optimization_interval_hours
unless db_interval < 1
spawn do
loop do
Storage.default.optimize
sleep db_interval.hours
end
end end
end end
end end
@@ -92,7 +120,6 @@ class Library
"Attempting to create it" "Attempting to create it"
Dir.mkdir_p @dir Dir.mkdir_p @dir
end end
@title_ids.clear
storage = Storage.new auto_close: false storage = Storage.new auto_close: false
@@ -103,6 +130,7 @@ class Library
.map { |path| Title.new path, "", storage, self } .map { |path| Title.new path, "", storage, self }
.select { |title| !(title.entries.empty? && title.titles.empty?) } .select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort { |a, b| a.title <=> b.title } .sort { |a, b| a.title <=> b.title }
.tap { |_| @title_ids.clear }
.each do |title| .each do |title|
@title_hash[title.id] = title @title_hash[title.id] = title
@title_ids << title.id @title_ids << title.id
@@ -194,4 +222,50 @@ class Library
.sample(ENTRIES_IN_HOME_SECTIONS) .sample(ENTRIES_IN_HOME_SECTIONS)
.shuffle .shuffle
end end
def thumbnail_generation_progress
return 0 if @entries_count == 0
@thumbnails_count / @entries_count
end
def generate_thumbnails
if @thumbnails_count > 0
Logger.debug "Thumbnail generation in progress"
return
end
Logger.info "Starting thumbnail generation"
entries = deep_titles.map(&.deep_entries).flatten.reject &.err_msg
@entries_count = entries.size
@thumbnails_count = 0
# Report generation progress regularly
spawn do
loop do
unless @thumbnails_count == 0
Logger.debug "Thumbnail generation progress: " \
"#{(thumbnail_generation_progress * 100).round 1}%"
end
# Generation is completed. We reset the count to 0 to allow subsequent
# calls to the function, and break from the loop to stop the progress
# report fiber
if thumbnail_generation_progress.to_i == 1
@thumbnails_count = 0
break
end
sleep 10.seconds
end
end
entries.each do |e|
unless e.get_thumbnail
e.generate_thumbnail
# Sleep after each generation to minimize the impact on disk IO
# and CPU
sleep 0.5.seconds
end
@thumbnails_count += 1
end
Logger.info "Thumbnail generation finished"
end
end end
+1 -1
View File
@@ -56,7 +56,7 @@ class Title
def to_json(json : JSON::Builder) def to_json(json : JSON::Builder)
json.object do json.object do
{% for str in ["dir", "title", "id", "encoded_title"] %} {% for str in ["dir", "title", "id"] %}
json.field {{str}}, @{{str.id}} json.field {{str}}, @{{str.id}}
{% end %} {% end %}
json.field "display_name", display_name json.field "display_name", display_name
+10
View File
@@ -57,6 +57,16 @@ struct Image
def initialize(@data, @mime, @filename, @size) def initialize(@data, @mime, @filename, @size)
end end
def self.from_db(res : DB::ResultSet)
img = Image.allocate
res.read String
img.data = res.read Bytes
img.filename = res.read String
img.mime = res.read String
img.size = res.read Int32
img
end
end end
class TitleInfo class TitleInfo
+6 -4
View File
@@ -26,9 +26,9 @@ class Logger
{% end %} {% end %}
@log = Log.for("") @log = Log.for("")
@backend = Log::IOBackend.new @backend = Log::IOBackend.new
@backend.formatter = ->(entry : Log::Entry, io : IO) do
format_proc = ->(entry : Log::Entry, io : IO) do
color = :default color = :default
{% begin %} {% begin %}
case entry.severity.label.to_s().downcase case entry.severity.label.to_s().downcase
@@ -45,12 +45,14 @@ class Logger
io << entry.message io << entry.message
end end
Log.builder.bind "*", @@severity, @backend @backend.formatter = Log::Formatter.new &format_proc
Log.setup @@severity, @backend
end end
# Ignores @@severity and always log msg # Ignores @@severity and always log msg
def log(msg) def log(msg)
@backend.write Log::Entry.new "", Log::Severity::None, msg, nil @backend.write Log::Entry.new "", Log::Severity::None, msg,
Log::Metadata.empty, nil
end end
def self.log(msg) def self.log(msg)
+34
View File
@@ -0,0 +1,34 @@
# On ARM, connecting to the SQLite DB from a spawned fiber would crash
# https://github.com/crystal-lang/crystal-sqlite3/issues/30
# This is a temporary workaround that forces the relevant code to run in the
# main fiber
class MainFiber
@@channel = Channel(-> Nil).new
@@done = Channel(Bool).new
@@main_fiber = Fiber.current
def self.start_and_block
loop do
if proc = @@channel.receive
begin
proc.call
ensure
@@done.send true
end
end
Fiber.yield
end
end
def self.run(&block : -> Nil)
if @@main_fiber == Fiber.current
block.call
else
@@channel.send block
until @@done.receive
Fiber.yield
end
end
end
end
-1
View File
@@ -1,4 +1,3 @@
require "http/client"
require "json" require "json"
require "csv" require "csv"
require "../rename" require "../rename"
+16 -3
View File
@@ -1,12 +1,12 @@
require "./api" require "./api"
require "zip" require "compress/zip"
module MangaDex module MangaDex
class PageJob class PageJob
property success = false property success = false
property url : String property url : String
property filename : String property filename : String
property writer : Zip::Writer property writer : Compress::Zip::Writer
property tries_remaning : Int32 property tries_remaning : Int32
def initialize(@url, @filename, @writer, @tries_remaning) def initialize(@url, @filename, @writer, @tries_remaning)
@@ -27,6 +27,7 @@ module MangaDex
def pop : Queue::Job? def pop : Queue::Job?
job = nil job = nil
MainFiber.run do
DB.open "sqlite3://#{@queue.path}" do |db| DB.open "sqlite3://#{@queue.path}" do |db|
begin begin
db.query_one "select * from queue where id not like '%-%' " \ db.query_one "select * from queue where id not like '%-%' " \
@@ -37,6 +38,7 @@ module MangaDex
rescue rescue
end end
end end
end
job job
end end
@@ -67,7 +69,7 @@ module MangaDex
# Find the number of digits needed to store the number of pages # Find the number of digits needed to store the number of pages
len = Math.log10(chapter.pages.size).to_i + 1 len = Math.log10(chapter.pages.size).to_i + 1
writer = Zip::Writer.new zip_path writer = Compress::Zip::Writer.new zip_path
# Create a buffered channel. It works as an FIFO queue # Create a buffered channel. It works as an FIFO queue
channel = Channel(PageJob).new chapter.pages.size channel = Channel(PageJob).new chapter.pages.size
spawn do spawn do
@@ -89,6 +91,7 @@ module MangaDex
end end
channel.send page_job channel.send page_job
break unless @queue.exists? job
end end
end end
@@ -96,6 +99,9 @@ module MangaDex
page_jobs = [] of PageJob page_jobs = [] of PageJob
chapter.pages.size.times do chapter.pages.size.times do
page_job = channel.receive page_job = channel.receive
break unless @queue.exists? job
Logger.debug "[#{page_job.success ? "success" : "failed"}] " \ Logger.debug "[#{page_job.success ? "success" : "failed"}] " \
"#{page_job.url}" "#{page_job.url}"
page_jobs << page_job page_jobs << page_job
@@ -108,6 +114,13 @@ module MangaDex
Logger.error msg Logger.error msg
end end
end end
unless @queue.exists? job
Logger.debug "Download cancelled"
@downloading = false
next
end
fail_count = page_jobs.count { |j| !j.success } fail_count = page_jobs.count { |j| !j.success }
Logger.debug "Download completed. " \ Logger.debug "Download completed. " \
"#{fail_count}/#{page_jobs.size} failed" "#{fail_count}/#{page_jobs.size} failed"
+31 -6
View File
@@ -1,12 +1,29 @@
require "./config" require "./config"
require "./queue" require "./queue"
require "./server" require "./server"
require "./main_fiber"
require "./mangadex/*" require "./mangadex/*"
require "option_parser" require "option_parser"
require "clim" require "clim"
require "./plugin/*" require "./plugin/*"
MANGO_VERSION = "0.10.0" MANGO_VERSION = "0.17.0"
# From http://www.network-science.de/ascii/
BANNER = %{
_| _|
_|_| _|_| _|_|_| _|_|_| _|_|_| _|_|
_| _| _| _| _| _| _| _| _| _| _|
_| _| _| _| _| _| _| _| _| _|
_| _| _|_|_| _| _| _|_|_| _|_|
_|
_|_|
}
DESCRIPTION = "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}"
macro common_option macro common_option
option "-c PATH", "--config=PATH", type: String, option "-c PATH", "--config=PATH", type: String,
@@ -22,20 +39,28 @@ end
class CLI < Clim class CLI < Clim
main do main do
desc "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}" desc DESCRIPTION
usage "mango [sub_command] [options]" usage "mango [sub_command] [options]"
help short: "-h" help short: "-h"
version "Version #{MANGO_VERSION}", short: "-v" version "Version #{MANGO_VERSION}", short: "-v"
common_option common_option
run do |opts| run do |opts|
puts BANNER
puts DESCRIPTION
puts
# empty ARGV so it won't be passed to Kemal
ARGV.clear
Config.load(opts.config).set_current Config.load(opts.config).set_current
MangaDex::Downloader.default MangaDex::Downloader.default
Plugin::Downloader.default Plugin::Downloader.default
# empty ARGV so it won't be passed to Kemal spawn do
ARGV.clear Server.new.start
server = Server.new end
server.start
MainFiber.start_and_block
end end
sub "admin" do sub "admin" do
+11 -1
View File
@@ -8,6 +8,7 @@ class Plugin
def pop : Queue::Job? def pop : Queue::Job?
job = nil job = nil
MainFiber.run do
DB.open "sqlite3://#{@queue.path}" do |db| DB.open "sqlite3://#{@queue.path}" do |db|
begin begin
db.query_one "select * from queue where id like '%-%' " \ db.query_one "select * from queue where id like '%-%' " \
@@ -18,6 +19,7 @@ class Plugin
rescue rescue
end end
end end
end
job job
end end
@@ -51,7 +53,7 @@ class Plugin
end end
zip_path = File.join manga_dir, "#{chapter_title}.cbz.part" zip_path = File.join manga_dir, "#{chapter_title}.cbz.part"
writer = Zip::Writer.new zip_path writer = Compress::Zip::Writer.new zip_path
rescue e rescue e
@queue.set_status Queue::JobStatus::Error, job @queue.set_status Queue::JobStatus::Error, job
unless e.message.nil? unless e.message.nil?
@@ -64,6 +66,8 @@ class Plugin
fail_count = 0 fail_count = 0
while page = plugin.next_page while page = plugin.next_page
break unless @queue.exists? job
fn = process_filename page["filename"].as_s fn = process_filename page["filename"].as_s
url = page["url"].as_s url = page["url"].as_s
headers = HTTP::Headers.new headers = HTTP::Headers.new
@@ -107,6 +111,12 @@ class Plugin
end end
end end
unless @queue.exists? job
Logger.debug "Download cancelled"
@downloading = false
return
end
Logger.debug "Download completed. #{fail_count}/#{pages} failed" Logger.debug "Download completed. #{fail_count}/#{pages} failed"
writer.close writer.close
filename = File.join File.dirname(zip_path), File.basename(zip_path, filename = File.join File.dirname(zip_path), File.basename(zip_path,
-1
View File
@@ -1,6 +1,5 @@
require "duktape/runtime" require "duktape/runtime"
require "myhtml" require "myhtml"
require "http"
require "xml" require "xml"
class Plugin class Plugin
+43
View File
@@ -119,6 +119,7 @@ class Queue
"Attepmting to create it" "Attepmting to create it"
Dir.mkdir_p dir Dir.mkdir_p dir
end end
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
begin begin
db.exec "create table if not exists queue " \ db.exec "create table if not exists queue " \
@@ -138,11 +139,13 @@ class Queue
end end
end end
end end
end
# Push an array of jobs into the queue, and return the number of jobs # Push an array of jobs into the queue, and return the number of jobs
# inserted. Any job already exists in the queue will be ignored. # inserted. Any job already exists in the queue will be ignored.
def push(jobs : Array(Job)) def push(jobs : Array(Job))
start_count = self.count start_count = self.count
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
jobs.each do |job| jobs.each do |job|
db.exec "insert or ignore into queue values " \ db.exec "insert or ignore into queue values " \
@@ -152,16 +155,19 @@ class Queue
job.success_count, job.fail_count, job.time.to_unix_ms job.success_count, job.fail_count, job.time.to_unix_ms
end end
end end
end
self.count - start_count self.count - start_count
end end
def reset(id : String) def reset(id : String)
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status = 0, status_message = '', " \ db.exec "update queue set status = 0, status_message = '', " \
"pages = 0, success_count = 0, fail_count = 0 " \ "pages = 0, success_count = 0, fail_count = 0 " \
"where id = (?)", id "where id = (?)", id
end end
end end
end
def reset(job : Job) def reset(job : Job)
self.reset job.id self.reset job.id
@@ -169,91 +175,128 @@ class Queue
# Reset all failed tasks (missing pages and error) # Reset all failed tasks (missing pages and error)
def reset def reset
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status = 0, status_message = '', " \ db.exec "update queue set status = 0, status_message = '', " \
"pages = 0, success_count = 0, fail_count = 0 " \ "pages = 0, success_count = 0, fail_count = 0 " \
"where status = 2 or status = 4" "where status = 2 or status = 4"
end end
end end
end
def delete(id : String) def delete(id : String)
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from queue where id = (?)", id db.exec "delete from queue where id = (?)", id
end end
end end
end
def delete(job : Job) def delete(job : Job)
self.delete job.id self.delete job.id
end end
def exists?(id : String)
res = false
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
res = db.query_one "select count(*) from queue where id = (?)", id,
as: Bool
end
end
res
end
def exists?(job : Job)
self.exists? job.id
end
def delete_status(status : JobStatus) def delete_status(status : JobStatus)
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from queue where status = (?)", status.to_i db.exec "delete from queue where status = (?)", status.to_i
end end
end end
end
def count_status(status : JobStatus) def count_status(status : JobStatus)
num = 0 num = 0
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
num = db.query_one "select count(*) from queue where " \ num = db.query_one "select count(*) from queue where " \
"status = (?)", status.to_i, as: Int32 "status = (?)", status.to_i, as: Int32
end end
end
num num
end end
def count def count
num = 0 num = 0
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
num = db.query_one "select count(*) from queue", as: Int32 num = db.query_one "select count(*) from queue", as: Int32
end end
end
num num
end end
def set_status(status : JobStatus, job : Job) def set_status(status : JobStatus, job : Job)
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status = (?) where id = (?)", db.exec "update queue set status = (?) where id = (?)",
status.to_i, job.id status.to_i, job.id
end end
end end
end
def get_all def get_all
jobs = [] of Job jobs = [] of Job
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
jobs = db.query_all "select * from queue order by time" do |rs| jobs = db.query_all "select * from queue order by time" do |rs|
Job.from_query_result rs Job.from_query_result rs
end end
end end
end
jobs jobs
end end
def add_success(job : Job) def add_success(job : Job)
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set success_count = success_count + 1 " \ db.exec "update queue set success_count = success_count + 1 " \
"where id = (?)", job.id "where id = (?)", job.id
end end
end end
end
def add_fail(job : Job) def add_fail(job : Job)
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set fail_count = fail_count + 1 " \ db.exec "update queue set fail_count = fail_count + 1 " \
"where id = (?)", job.id "where id = (?)", job.id
end end
end end
end
def set_pages(pages : Int32, job : Job) def set_pages(pages : Int32, job : Job)
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set pages = (?), success_count = 0, " \ db.exec "update queue set pages = (?), success_count = 0, " \
"fail_count = 0 where id = (?)", pages, job.id "fail_count = 0 where id = (?)", pages, job.id
end end
end end
end
def add_message(msg : String, job : Job) def add_message(msg : String, job : Job)
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status_message = " \ db.exec "update queue set status_message = " \
"status_message || (?) || (?) where id = (?)", "status_message || (?) || (?) where id = (?)",
"\n", msg, job.id "\n", msg, job.id
end end
end end
end
def <<(downloader : Downloader) def <<(downloader : Downloader)
@downloaders << downloader @downloaders << downloader
+380 -17
View File
@@ -1,9 +1,171 @@
require "./router" require "./router"
require "../mangadex/*" require "../mangadex/*"
require "../upload" require "../upload"
require "koa"
class APIRouter < Router class APIRouter < Router
@@api_json : String?
API_VERSION = "0.1.0"
macro s(fields)
{
{% for field in fields %}
{{field}} => "string",
{% end %}
}
end
def initialize def initialize
Koa.init "Mango API", version: API_VERSION, desc: <<-MD
# A Word of Caution
This API was designed for internal use only, and the design doesn't comply with the resources convention of a RESTful API. Because of this, most of the API endpoints listed here will soon be updated and removed in future versions of Mango, so use them at your own risk!
# Authentication
All endpoints require authentication. After logging in, your session ID would be stored as a cookie named `mango-sessid-#{Config.current.port}`, which can be used to authenticate the API access. Note that all admin API endpoints (`/api/admin/...`) require the logged-in user to have admin access.
# Terminologies
- Entry: An entry is a `cbz`/`cbr` file in your library. Depending on how you organize your manga collection, an entry can contain a chapter, a volume or even an entire manga.
- Title: A title contains a list of entries and optionally some sub-titles. For example, you can have a title to store a manga, and it contains a list of sub-titles representing the volumes in the manga. Each sub-title would then contain a list of entries representing the chapters in the volume.
- Library: The library is a collection of top-level titles, and it does not contain entries (though the titles do). A Mango instance can only have one library.
MD
Koa.cookie_auth "cookie", "mango-sessid-#{Config.current.port}"
Koa.global_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.
MD
Koa.binary "binary", desc: "A binary file"
Koa.array "entryAry", "$entry", desc: "An array of entries"
Koa.array "titleAry", "$title", desc: "An array of titles"
Koa.array "strAry", "string", desc: "An array of strings"
entry_schema = {
"pages" => "integer",
"mtime" => "integer",
}.merge s %w(zip_path title size id title_id display_name cover_url)
Koa.object "entry", entry_schema, desc: "An entry in a book"
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)"
Koa.object "library", {
"dir" => "string",
"titles" => "$titleAry",
}, 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?",
"error" => "string?",
}
Koa.object "ids", {
"ids" => "$strAry",
}
Koa.describe "Returns a page in a manga entry"
Koa.path "tid", desc: "Title ID"
Koa.path "eid", desc: "Entry ID"
Koa.path "page", type: "integer", desc: "The page number to return (starts from 1)"
Koa.response 200, ref: "$binary", media_type: "image/*"
Koa.response 500, "Page not found or not readable"
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"]
@@ -26,6 +188,37 @@ class APIRouter < Router
end end
end end
Koa.describe "Returns the cover image of a manga entry"
Koa.path "tid", desc: "Title ID"
Koa.path "eid", desc: "Entry ID"
Koa.response 200, ref: "$binary", media_type: "image/*"
Koa.response 500, "Page not found or not readable"
get "/api/cover/:tid/:eid" do |env|
begin
tid = env.params.url["tid"]
eid = env.params.url["eid"]
title = @context.library.get_title tid
raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
img = entry.get_thumbnail || entry.read_page 1
raise "Failed to get cover of `#{title.title}/#{entry.title}`" \
if img.nil?
send_img env, img
rescue e
@context.error e
env.response.status_code = 500
e.message
end
end
Koa.describe "Returns the book with title `tid`"
Koa.path "tid", desc: "Title ID"
Koa.response 200, ref: "$title"
Koa.response 404, "Title not found"
get "/api/book/:tid" do |env| get "/api/book/:tid" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
@@ -35,15 +228,20 @@ class APIRouter < Router
send_json env, title.to_json send_json env, title.to_json
rescue e rescue e
@context.error e @context.error e
env.response.status_code = 500 env.response.status_code = 404
e.message e.message
end end
end end
get "/api/book" do |env| Koa.describe "Returns the entire library with all titles and entries"
Koa.response 200, ref: "$library"
get "/api/library" do |env|
send_json env, @context.library.to_json send_json env, @context.library.to_json
end end
Koa.describe "Triggers a library scan"
Koa.tag "admin"
Koa.response 200, ref: "$scanResult"
post "/api/admin/scan" do |env| post "/api/admin/scan" do |env|
start = Time.utc start = Time.utc
@context.library.scan @context.library.scan
@@ -54,7 +252,27 @@ class APIRouter < Router
}.to_json }.to_json
end end
post "/api/admin/user/delete/:username" do |env| Koa.describe "Returns the thumbnail generation progress between 0 and 1"
Koa.tag "admin"
Koa.response 200, ref: "$progressResult"
get "/api/admin/thumbnail_progress" do |env|
send_json env, {
"progress" => Library.default.thumbnail_generation_progress,
}.to_json
end
Koa.describe "Triggers a thumbnail generation"
Koa.tag "admin"
post "/api/admin/generate_thumbnails" do |env|
spawn do
Library.default.generate_thumbnails
end
end
Koa.describe "Deletes a user with `username`"
Koa.tag "admin"
Koa.response 200, ref: "$result"
delete "/api/admin/user/delete/:username" do |env|
begin begin
username = env.params.url["username"] username = env.params.url["username"]
@context.storage.delete_user username @context.storage.delete_user username
@@ -69,13 +287,24 @@ class APIRouter < Router
end end
end end
post "/api/progress/:title/:page" do |env| Koa.describe "Updates the reading progress of an entry or the whole title for the current user", <<-MD
When `eid` is provided, sets the reading progress of the entry to `page`.
When `eid` is omitted, updates the progress of the entire title. Specifically:
- if `page` is 0, marks the entire title as unread
- otherwise, marks the entire title as read
MD
Koa.path "tid", desc: "Title ID"
Koa.query "eid", desc: "Entry ID", required: false
Koa.path "page", desc: "The new page number indicating the progress"
Koa.response 200, ref: "$result"
put "/api/progress/:tid/:page" do |env|
begin begin
username = get_username env username = get_username env
title = (@context.library.get_title env.params.url["title"]) title = (@context.library.get_title env.params.url["tid"]).not_nil!
.not_nil!
page = env.params.url["page"].to_i page = env.params.url["page"].to_i
entry_id = env.params.query["entry"]? entry_id = env.params.query["eid"]?
if !entry_id.nil? if !entry_id.nil?
entry = title.get_entry(entry_id).not_nil! entry = title.get_entry(entry_id).not_nil!
@@ -97,10 +326,15 @@ class APIRouter < Router
end end
end end
post "/api/bulk-progress/:action/:title" do |env| 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 "tid", desc: "Title ID"
Koa.body ref: "$ids", desc: "An array of entry IDs"
Koa.response 200, ref: "$result"
put "/api/bulk_progress/:action/:tid" do |env|
begin begin
username = get_username env username = get_username env
title = (@context.library.get_title env.params.url["title"]).not_nil! title = (@context.library.get_title env.params.url["tid"]).not_nil!
action = env.params.url["action"] action = env.params.url["action"]
ids = env.params.json["ids"].as(Array).map &.as_s ids = env.params.json["ids"].as(Array).map &.as_s
@@ -119,12 +353,20 @@ class APIRouter < Router
end end
end end
post "/api/admin/display_name/:title/:name" do |env| 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`.
MD
Koa.tag "admin"
Koa.path "tid", desc: "Title ID"
Koa.query "eid", desc: "Entry ID", required: false
Koa.path "name", desc: "The new display name"
Koa.response 200, ref: "$result"
put "/api/admin/display_name/:tid/:name" do |env|
begin begin
title = (@context.library.get_title env.params.url["title"]) title = (@context.library.get_title env.params.url["tid"])
.not_nil! .not_nil!
name = env.params.url["name"] name = env.params.url["name"]
entry = env.params.query["entry"]? entry = env.params.query["eid"]?
if entry.nil? if entry.nil?
title.set_display_name name title.set_display_name name
else else
@@ -142,6 +384,12 @@ class APIRouter < Router
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| get "/api/admin/mangadex/manga/:id" do |env|
begin begin
id = env.params.url["id"] id = env.params.url["id"]
@@ -154,6 +402,12 @@ class APIRouter < Router
end end
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| post "/api/admin/mangadex/download" do |env|
begin begin
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h } chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
@@ -178,6 +432,23 @@ class APIRouter < Router
end end
end end
ws "/api/admin/mangadex/queue" do |socket, env|
interval_raw = env.params.query["interval"]?
interval = (interval_raw.to_i? if interval_raw) || 5
loop do
socket.send({
"jobs" => @context.queue.get_all,
"paused" => @context.queue.paused?,
}.to_json)
sleep interval.seconds
end
end
Koa.describe "Returns the current download queue", <<-MD
On error, returns a JSON that contains the error message in the `error` field.
MD
Koa.tag "admin"
Koa.response 200, ref: "$jobs"
get "/api/admin/mangadex/queue" do |env| get "/api/admin/mangadex/queue" do |env|
begin begin
jobs = @context.queue.get_all jobs = @context.queue.get_all
@@ -194,6 +465,19 @@ class APIRouter < Router
end end
end end
Koa.describe "Perform an action on a download job or all jobs in the queue", <<-MD
The `action` parameter can be `delete`, `retry`, `pause` or `resume`.
When `action` is `pause` or `resume`, pauses or resumes the download queue, respectively.
When `action` is set to `delete`, the behavior depends on `id`. If `id` is provided, deletes the specific job identified by the ID. Otherwise, deletes all **completed** jobs 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
Koa.tag "admin"
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.response 200, ref: "$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"]
@@ -228,6 +512,22 @@ class APIRouter < Router
end end
end end
Koa.describe "Uploads a file to the server", <<-MD
Currently the only supported value for the `target` parameter is `cover`.
### Cover
Uploads a cover image for a title or an entry.
Query parameters:
- `tid`: A title ID
- `eid`: (Optional) An entry ID
When `eid` is omitted, the new cover image will be applied to the title. Otherwise, applies the image to the specified entry.
MD
Koa.tag "admin"
Koa.body type: "multipart/form-data", ref: "$binaryUpload"
Koa.response 200, ref: "$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"]
@@ -242,8 +542,8 @@ class APIRouter < Router
case target case target
when "cover" when "cover"
title_id = env.params.query["title"] title_id = env.params.query["tid"]
entry_id = env.params.query["entry"]? entry_id = env.params.query["eid"]?
title = @context.library.get_title(title_id).not_nil! title = @context.library.get_title(title_id).not_nil!
unless SUPPORTED_IMG_TYPES.includes? \ unless SUPPORTED_IMG_TYPES.includes? \
@@ -282,10 +582,14 @@ class APIRouter < Router
end end
end end
post "/api/admin/plugin/list" do |env| Koa.describe "Lists the chapters in a title from a plugin"
Koa.tag "admin"
Koa.body ref: "$pluginListBody"
Koa.response 200, ref: "$pluginList"
get "/api/admin/plugin/list" do |env|
begin begin
query = env.params.json["query"].as String query = env.params.query["query"].as String
plugin = Plugin.new env.params.json["plugin"].as String plugin = Plugin.new env.params.query["plugin"].as String
json = plugin.list_chapters query json = plugin.list_chapters query
chapters = json["chapters"] chapters = json["chapters"]
@@ -304,6 +608,10 @@ class APIRouter < Router
end end
end end
Koa.describe "Adds a list of chapters from a plugin to the download queue"
Koa.tag "admin"
Koa.body ref: "$pluginDownload"
Koa.response 200, ref: "$successFailCount"
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
@@ -332,5 +640,60 @@ class APIRouter < Router
}.to_json }.to_json
end end
end end
Koa.describe "Returns the image dimensions of all pages in an entry"
Koa.path "tid", desc: "A title ID"
Koa.path "eid", desc: "An entry ID"
Koa.response 200, ref: "$dimensionResult"
get "/api/dimensions/:tid/:eid" do |env|
begin
tid = env.params.url["tid"]
eid = env.params.url["eid"]
title = @context.library.get_title tid
raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
sizes = entry.page_dimensions
send_json env, {
"success" => true,
"dimensions" => sizes,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Downloads an entry"
Koa.path "tid", desc: "A title ID"
Koa.path "eid", desc: "An entry ID"
Koa.response 200, ref: "$binary"
Koa.response 404, "Entry not found"
get "/api/download/:tid/:eid" do |env|
begin
title = (@context.library.get_title env.params.url["tid"]).not_nil!
entry = (title.get_entry env.params.url["eid"]).not_nil!
send_attachment env, entry.zip_path
rescue e
@context.error e
env.response.status_code = 404
end
end
doc = Koa.generate
@@api_json = doc.to_json if doc
get "/openapi.json" do |env|
if @@api_json
send_json env, @@api_json
else
env.response.status_code = 404
end
end
end end
end end
+4
View File
@@ -113,5 +113,9 @@ class MainRouter < Router
env.response.status_code = 500 env.response.status_code = 500
end end
end end
get "/api" do |env|
render "src/views/api.html.ecr"
end
end end
end end
-12
View File
@@ -16,17 +16,5 @@ class OPDSRouter < Router
env.response.status_code = 404 env.response.status_code = 404
end end
end end
get "/opds/download/:title/:entry" do |env|
begin
title = (@context.library.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
send_attachment env, entry.zip_path
rescue e
@context.error e
env.response.status_code = 404
end
end
end end
end end
+5 -21
View File
@@ -12,11 +12,7 @@ class ReaderRouter < Router
next layout "reader-error" if entry.err_msg next layout "reader-error" if entry.err_msg
# load progress # load progress
page = entry.load_progress username page = [1, entry.load_progress username].max
# we go back 2 * `IMGS_PER_PAGE` pages. the infinite scroll
# library perloads a few pages in advance, and the user
# might not have actually read them
page = [page - 2 * IMGS_PER_PAGE, 1].max
# start from page 1 if the user has finished reading the entry # start from page 1 if the user has finished reading the entry
page = 1 if entry.finished? username page = 1 if entry.finished? username
@@ -32,29 +28,17 @@ class ReaderRouter < Router
begin begin
base_url = Config.current.base_url base_url = Config.current.base_url
username = get_username env
title = (@context.library.get_title env.params.url["title"]).not_nil! title = (@context.library.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!
page = env.params.url["page"].to_i page = env.params.url["page"].to_i
raise "" if page > entry.pages || page <= 0 raise "" if page > entry.pages || page <= 0
# save progress
username = get_username env
entry.save_progress username, page
pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min)
urls = pages.map { |idx|
"#{base_url}api/page/#{title.id}/#{entry.id}/#{idx}"
}
reader_urls = pages.map { |idx|
"#{base_url}reader/#{title.id}/#{entry.id}/#{idx}"
}
next_page = page + IMGS_PER_PAGE
next_url = next_entry_url = nil
exit_url = "#{base_url}book/#{title.id}" exit_url = "#{base_url}book/#{title.id}"
next_entry_url = nil
next_entry = entry.next_entry username next_entry = entry.next_entry username
unless next_page > entry.pages
next_url = "#{base_url}reader/#{title.id}/#{entry.id}/#{next_page}"
end
unless next_entry.nil? unless next_entry.nil?
next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}" next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}"
end end
+89 -7
View File
@@ -32,11 +32,14 @@ class Storage
"Attepmting to create it" "Attepmting to create it"
Dir.mkdir_p dir Dir.mkdir_p dir
end end
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
begin begin
# We create the `ids` table first. even if the uses has an db.exec "create table thumbnails " \
# early version installed and has the `user` table only, "(id text, data blob, filename text, " \
# we will still be able to create `ids` "mime text, size integer)"
db.exec "create unique index tn_index on thumbnails (id)"
db.exec "create table ids" \ db.exec "create table ids" \
"(path text, id text, is_title integer)" "(path text, id text, is_title integer)"
db.exec "create unique index path_idx on ids (path)" db.exec "create unique index path_idx on ids (path)"
@@ -66,6 +69,7 @@ class Storage
@db = DB.open "sqlite3://#{@path}" @db = DB.open "sqlite3://#{@path}"
end end
end end
end
macro init_admin macro init_admin
random_pw = random_str random_pw = random_str
@@ -87,6 +91,8 @@ class Storage
end end
def verify_user(username, password) def verify_user(username, password)
out_token = nil
MainFiber.run do
get_db do |db| get_db do |db|
begin begin
hash, token = db.query_one "select password, token from " \ hash, token = db.query_one "select password, token from " \
@@ -94,24 +100,29 @@ class Storage
username, as: {String, String?} username, as: {String, String?}
unless verify_password hash, password unless verify_password hash, password
Logger.debug "Password does not match the hash" Logger.debug "Password does not match the hash"
return nil next
end end
Logger.debug "User #{username} verified" Logger.debug "User #{username} verified"
return token if token if token
out_token = token
next
end
token = random_str token = random_str
Logger.debug "Updating token for #{username}" Logger.debug "Updating token for #{username}"
db.exec "update users set token = (?) where username = (?)", db.exec "update users set token = (?) where username = (?)",
token, username token, username
return token out_token = token
rescue e rescue e
Logger.error "Error when verifying user #{username}: #{e}" Logger.error "Error when verifying user #{username}: #{e}"
return nil
end end
end end
end end
out_token
end
def verify_token(token) def verify_token(token)
username = nil username = nil
MainFiber.run do
get_db do |db| get_db do |db|
begin begin
username = db.query_one "select username from users where " \ username = db.query_one "select username from users where " \
@@ -120,11 +131,13 @@ class Storage
Logger.debug "Unable to verify token" Logger.debug "Unable to verify token"
end end
end end
end
username username
end end
def verify_admin(token) def verify_admin(token)
is_admin = false is_admin = false
MainFiber.run do
get_db do |db| get_db do |db|
begin begin
is_admin = db.query_one "select admin from users where " \ is_admin = db.query_one "select admin from users where " \
@@ -133,11 +146,13 @@ class Storage
Logger.debug "Unable to verify user as admin" Logger.debug "Unable to verify user as admin"
end end
end end
end
is_admin is_admin
end end
def list_users def list_users
results = Array(Tuple(String, Bool)).new results = Array(Tuple(String, Bool)).new
MainFiber.run do
get_db do |db| get_db do |db|
db.query "select username, admin from users" do |rs| db.query "select username, admin from users" do |rs|
rs.each do rs.each do
@@ -145,6 +160,7 @@ class Storage
end end
end end
end end
end
results results
end end
@@ -152,17 +168,20 @@ class Storage
validate_username username validate_username username
validate_password password validate_password password
admin = (admin ? 1 : 0) admin = (admin ? 1 : 0)
MainFiber.run do
get_db do |db| get_db do |db|
hash = hash_password password hash = hash_password password
db.exec "insert into users values (?, ?, ?, ?)", db.exec "insert into users values (?, ?, ?, ?)",
username, hash, nil, admin username, hash, nil, admin
end end
end end
end
def update_user(original_username, username, password, admin) def update_user(original_username, username, password, admin)
admin = (admin ? 1 : 0) admin = (admin ? 1 : 0)
validate_username username validate_username username
validate_password password unless password.empty? validate_password password unless password.empty?
MainFiber.run do
get_db do |db| get_db do |db|
if password.empty? if password.empty?
db.exec "update users set username = (?), admin = (?) " \ db.exec "update users set username = (?), admin = (?) " \
@@ -176,14 +195,18 @@ class Storage
end end
end end
end end
end
def delete_user(username) def delete_user(username)
MainFiber.run do
get_db do |db| get_db do |db|
db.exec "delete from users where username = (?)", username db.exec "delete from users where username = (?)", username
end end
end end
end
def logout(token) def logout(token)
MainFiber.run do
get_db do |db| get_db do |db|
begin begin
db.exec "update users set token = (?) where token = (?)", nil, token db.exec "update users set token = (?) where token = (?)", nil, token
@@ -191,13 +214,16 @@ class Storage
end end
end end
end end
end
def get_id(path, is_title) def get_id(path, is_title)
id = nil id = nil
MainFiber.run do
get_db do |db| get_db do |db|
id = db.query_one? "select id from ids where path = (?)", path, id = db.query_one? "select id from ids where path = (?)", path,
as: {String} as: {String}
end end
end
id id
end end
@@ -206,6 +232,7 @@ class Storage
end end
def bulk_insert_ids def bulk_insert_ids
MainFiber.run do
get_db do |db| get_db do |db|
db.transaction do |tx| db.transaction do |tx|
@insert_ids.each do |tp| @insert_ids.each do |tp|
@@ -216,12 +243,67 @@ class Storage
end end
@insert_ids.clear @insert_ids.clear
end end
end
def save_thumbnail(id : String, img : Image)
MainFiber.run do
get_db do |db|
db.exec "insert into thumbnails values (?, ?, ?, ?, ?)", id, img.data,
img.filename, img.mime, img.size
end
end
end
def get_thumbnail(id : String) : Image?
img = nil
MainFiber.run do
get_db do |db|
db.query_one? "select * from thumbnails where id = (?)", id do |res|
img = Image.from_db res
end
end
end
img
end
def optimize
MainFiber.run do
Logger.info "Starting DB optimization"
get_db do |db|
trash_ids = [] of String
db.query "select path, id from ids" do |rs|
rs.each do
path = rs.read String
trash_ids << rs.read String unless File.exists? path
end
end
# Delete dangling IDs
db.exec "delete from ids where id in " \
"(#{trash_ids.map { |i| "'#{i}'" }.join ","})"
Logger.debug "#{trash_ids.size} dangling IDs deleted" \
if trash_ids.size > 0
# Delete dangling thumbnails
trash_thumbnails_count = db.query_one "select count(*) from " \
"thumbnails where id not in " \
"(select id from ids)", as: Int32
if trash_thumbnails_count > 0
db.exec "delete from thumbnails where id not in (select id from ids)"
Logger.info "#{trash_thumbnails_count} dangling thumbnails deleted"
end
end
Logger.info "DB optimization finished"
end
end
def close def close
MainFiber.run do
unless @db.nil? unless @db.nil?
@db.not_nil!.close @db.not_nil!.close
end end
end end
end
def to_json(json : JSON::Builder) def to_json(json : JSON::Builder)
json.string self json.string self
+43
View File
@@ -0,0 +1,43 @@
require "http_proxy"
# Monkey-patch `HTTP::Client` to make it respect the `*_PROXY`
# environment variables
module HTTP
class Client
private def self.exec(uri : URI, tls : TLSContext = nil)
Logger.debug "Setting proxy"
previous_def uri, tls do |client, path|
client.set_proxy get_proxy uri
yield client, path
end
end
end
end
private def get_proxy(uri : URI) : HTTP::Proxy::Client?
no_proxy = ENV["no_proxy"]? || ENV["NO_PROXY"]?
return if no_proxy &&
no_proxy.split(",").any? &.== uri.hostname
case uri.scheme
when "http"
env_to_proxy "http_proxy"
when "https"
env_to_proxy "https_proxy"
else
nil
end
end
private def env_to_proxy(key : String) : HTTP::Proxy::Client?
val = ENV[key.downcase]? || ENV[key.upcase]?
return if val.nil?
begin
uri = URI.parse val
HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil!,
username: uri.user, password: uri.password
rescue
nil
end
end
+6
View File
@@ -61,3 +61,9 @@ class String
self.chars.all? { |c| c.alphanumeric? || c == '_' } self.chars.all? { |c| c.alphanumeric? || c == '_' }
end end
end end
def env_is_true?(key : String) : Bool
val = ENV[key.upcase]? || ENV[key.downcase]?
return false unless val
val.downcase.in? "1", "true"
end
+3 -3
View File
@@ -2,9 +2,9 @@ def validate_username(username)
if username.size < 3 if username.size < 3
raise "Username should contain at least 3 characters" raise "Username should contain at least 3 characters"
end end
if (username =~ /^[A-Za-z0-9_]+$/).nil? if (username =~ /^[a-zA-Z_][a-zA-Z0-9_\-]*$/).nil?
raise "Username should contain alphanumeric characters " \ raise "Username can only contain alphanumeric characters, " \
"and underscores only" "underscores, and hyphens"
end end
end end
+17
View File
@@ -81,3 +81,20 @@ macro get_sort_opt
sort_opt = SortOptions.new sort_method, is_ascending sort_opt = SortOptions.new sort_method, is_ascending
end end
end end
module HTTP
class Client
private def self.exec(uri : URI, tls : TLSContext = nil)
previous_def uri, tls do |client, path|
if client.tls? && env_is_true? "DISABLE_SSL_VERIFICATION"
Logger.debug "Disabling SSL verification"
client.tls.verify_mode = OpenSSL::SSL::VerifyMode::NONE
end
Logger.debug "Setting read timeout"
client.read_timeout = Config.current.download_timeout_seconds.seconds
Logger.debug "Requesting #{uri}"
yield client, path
end
end
end
end
+14 -8
View File
@@ -1,11 +1,17 @@
<ul class="uk-list uk-list-large uk-list-divider"> <ul class="uk-list uk-list-large uk-list-divider" id="root" x-data="{progress : 1.0, generating : false, scanTitles: 0, scanMs: -1, scanning : false}">
<li data-url="<%= base_url %>admin/user">User Managerment</li> <li @click="location.href = '<%= base_url %>admin/user'">User Managerment</li>
<li onclick="if(!scanning){scan()}"> <li :class="{'nopointer' : scanning}" @click="scan()">
<span id="scan">Scan Library Files</span> <span :style="`${scanning ? 'color:grey' : ''}`">Scan Library Files</span>
<span id="scan-status" class="uk-align-right"> <div class="uk-align-right">
<div uk-spinner hidden></div> <div uk-spinner x-show="scanning"></div>
<span hidden></span> <span x-show="!scanning && scanMs > 0" x-text="`Scan ${scanTitles} titles in ${scanMs}ms`"></span>
</span> </div>
</li>
<li :class="{'nopointer' : generating}" @click="generateThumbnails()">
<span :style="`${generating ? 'color:grey' : ''}`">Generate Thumbnails</span>
<div class="uk-align-right">
<span x-show="generating && progress > 0" x-text="`${(progress * 100).toFixed(2)}%`"></span>
</div>
</li> </li>
<li class="nopointer"> <li class="nopointer">
<span>Theme</span> <span>Theme</span>
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge">
<title>Mango API Documentation</title>
<meta name="description" content="Mango - Manga Server and Web Reader">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<redoc spec-url="/openapi.json"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"></script>
</body>
</html>
@@ -0,0 +1,3 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
<script src="<%= base_url %>js/dots.js"></script>
+5 -2
View File
@@ -7,9 +7,12 @@
<link rel="stylesheet" href="<%= base_url %>css/uikit.css" /> <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 defer src="<%= base_url %>js/fontawesome.min.js"></script> <script defer src="<%= base_url %>js/fontawesome.min.js"></script>
<script defer src="<%= base_url %>js/solid.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 src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine.min.js" defer></script> <script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine.min.js"></script>
<script src="<%= base_url %>js/theme.js"></script> <script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine-ie11.min.js" defer></script>
<script src="<%= base_url %>js/common.js"></script>
</head> </head>
+48 -12
View File
@@ -1,13 +1,9 @@
<div id="root" x-data="{jobs: [], paused: undefined, loading: false, toggling: false}" x-init="load()">
<div class="uk-margin"> <div class="uk-margin">
<div id="actions" class="uk-margin"> <button class="uk-button uk-button-default" @click="jobAction('delete')">Delete Completed Tasks</button>
<button class="uk-button uk-button-default" onclick="remove()">Delete Completed Tasks</button> <button class="uk-button uk-button-default" @click="jobAction('retry')">Retry Failed Tasks</button>
<button class="uk-button uk-button-default" onclick="refresh()">Retry Failed Tasks</button> <button class="uk-button uk-button-default" @click="load()" :disabled="loading">Refresh Queue</button>
<button class="uk-button uk-button-default" onclick="load()">Refresh Queue</button> <button class="uk-button uk-button-default" x-show="paused !== undefined" x-text="paused ? 'Resume Download' : 'Pause Download'" @click="toggle()" :disabled="toggling"></button>
<button class="uk-button uk-button-default" onclick="toggle()" id="pause-resume-btn" hidden></button>
</div>
<div id="config" class="uk-margin">
<label><input id="auto-refresh" class="uk-checkbox" type="checkbox" checked> Auto Refresh</label>
</div>
</div> </div>
<table class="uk-table uk-table-striped uk-overflow-auto"> <table class="uk-table uk-table-striped uk-overflow-auto">
<thead> <thead>
@@ -21,12 +17,52 @@
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody>
<template x-for="job in jobs" :key="job">
<tr :id="`chapter-${job.id}`">
<template x-if="job.plugin_id">
<td x-text="job.title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/chapter/${job.id}`" x-text="job.title"></td>
</template>
<template x-if="job.plugin_id">
<td x-text="job.manga_title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/manga/${job.manga_id}`" x-text="job.manga_title"></td>
</template>
<td x-text="`${job.success_count}/${job.pages}`"></td>
<td x-text="`${moment(job.time).fromNow()}`"></td>
<td>
<span :class="statusClass(job.status)" x-text="job.status"></span>
<template x-if="job.status_message.length > 0">
<div class="uk-inline">
<span uk-icon="info"></span>
<div uk-dropdown x-text="job.status_message"></div>
</div>
</template>
</td>
<td x-text="`${job.plugin_id || ''}`"></td>
<td>
<a :onclick="`jobAction('delete', '${job.id}')`" uk-icon="trash"></a>
<template x-if="job.status_message.length > 0">
<a :onclick="`jobAction('retry', '${job.id}')`" uk-icon="refresh"></a>
</template>
</td>
</tr>
</template>
</tbody>
</table> </table>
</div>
<% content_for "script" do %> <% content_for "script" do %>
<script>
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, "");
</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/moment.js/2.24.0/moment.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-manager.js"></script> <script src="<%= base_url %>js/download-manager.js"></script>
+2 -3
View File
@@ -11,7 +11,7 @@
<dd>Update <code>config.yml</code> located at: <code><%= Config.current.path %></code></dd> <dd>Update <code>config.yml</code> located at: <code><%= Config.current.path %></code></dd>
<dt style="font-weight: 500;">Can't see your files yet?</dt> <dt style="font-weight: 500;">Can't see your files yet?</dt>
<dd> <dd>
You must wait <%= Config.current.scan_interval %> minutes for the library scan to complete You must wait <%= Config.current.scan_interval_minutes %> minutes for the library scan to complete
<% if is_admin %> <% if is_admin %>
, or manually re-scan from <a href="<%= base_url %>admin">Admin</a> , or manually re-scan from <a href="<%= base_url %>admin">Admin</a>
<% end %>. <% end %>.
@@ -77,8 +77,7 @@
<%- end -%> <%- end -%>
<% content_for "script" do %> <% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script> <%= render_component "dots-scripts" %>
<script src="<%= base_url %>js/dots.js"></script>
<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 %>
+4 -1
View File
@@ -67,10 +67,13 @@
</div> </div>
<div class="uk-section uk-section-small"> <div class="uk-section uk-section-small">
</div> </div>
<div class="uk-section uk-section-small"> <div class="uk-section uk-section-small" id="main-section">
<div class="uk-container uk-container-small"> <div class="uk-container uk-container-small">
<div id="alert"></div> <div id="alert"></div>
<%= content %> <%= content %>
<div class="uk-visible@m" id="totop-wrapper" x-data="{}" x-show="$('body').height() > 1.5 * $(window).height()">
<a href="#" uk-totop uk-scroll></a>
</div>
</div> </div>
</div> </div>
<script> <script>
+1 -2
View File
@@ -24,8 +24,7 @@
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script> <%= render_component "dots-scripts" %>
<script src="<%= base_url %>js/dots.js"></script>
<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 %>
+2 -2
View File
@@ -21,6 +21,7 @@
<% end %> <% end %>
<% title.entries.each do |e| %> <% title.entries.each do |e| %>
<% next if e.err_msg %>
<entry> <entry>
<title><%= HTML.escape(e.display_name) %></title> <title><%= HTML.escape(e.display_name) %></title>
<id>urn:mango:<%= e.id %></id> <id>urn:mango:<%= e.id %></id>
@@ -28,11 +29,10 @@
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" /> <link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" /> <link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>opds/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" /> <link rel="http://opds-spec.org/acquisition" href="<%= base_url %>api/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" /> <link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" />
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" /> <link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />
</entry> </entry>
<% end %> <% end %>
</feed> </feed>
+79 -15
View File
@@ -3,24 +3,70 @@
<%= render_component "head" %> <%= render_component "head" %>
<body> <body style="position:relative;">
<div class="uk-section uk-section-default uk-section-small reader-bg"> <div class="uk-section uk-section-default uk-section-small reader-bg"
id="root"
:style="mode === 'continuous' ? '' : 'padding:0'"
x-data="{
loading: true,
mode: 'continuous', // can be 'continuous', 'height' or 'width'
msg: 'Loading the web reader. Please wait...',
alertClass: 'uk-alert-primary',
items: [],
curItem: {},
flipAnimation: null
}">
<div @keydown.window.debounce="keyHandler($event)"></div>
<div class="uk-container uk-container-small"> <div class="uk-container uk-container-small">
<%- urls.each_with_index do |url, i| -%> <div id="alert"></div>
<img class="uk-align-center" data-src="<%= url %>" src="<%= base_url %>img/loading.gif" data-width data-height uk-img id="<%= reader_urls[i] %>" onclick="showControl(<%= pages.to_a[i] %>);"> <div x-show="loading">
<%- end -%> <div :class="alertClass" x-show="msg" uk-alert>
<%- if next_url -%> <p x-text="msg"></p>
<a class="next-url" href="<%= next_url %>"></a>
<%- end -%>
</div> </div>
</div>
</div>
<div
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
<div x-show="!loading && mode === 'continuous'" x-cloak>
<template x-for="item in items">
<img
uk-img
class="uk-align-center"
:data-src="item.url"
:width="item.width"
:height="item.height"
:id="item.id"
:onclick="`showControl('${item.id}')`"
/>
</template>
<%- if next_entry_url -%> <%- if next_entry_url -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= 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" hidden onclick="redirect('<%= exit_url %>')">Exit Reader</button> <button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="redirect('<%= exit_url %>')">Exit Reader</button>
<%- end -%> <%- end -%>
</div> </div>
<div id="hidden" hidden></div> <div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" style="height:100vh">
<img uk-img :class="{
'uk-align-center': true,
'uk-animation-slide-left': flipAnimation === 'left',
'uk-animation-slide-right': flipAnimation === 'right'
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" :onclick="`showControl('${curItem.id}')`" :style="`
width:${mode === 'width' ? '100vw' : 'auto'};
height:${mode === 'height' ? '100vh' : 'auto'};
margin-bottom:0;
`" />
<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;right:0; width:30%;height:100%;" @click="flipPage(true)"></div>
</div>
</div>
</div>
<div id="modal-sections" class="uk-flex-top" uk-modal> <div id="modal-sections" class="uk-flex-top" uk-modal>
<div class="uk-modal-dialog uk-margin-auto-vertical"> <div class="uk-modal-dialog uk-margin-auto-vertical">
@@ -34,7 +80,7 @@
<p id="progress-label"></p> <p id="progress-label"></p>
</div> </div>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="form-stacked-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"> <select id="page-select" class="uk-select">
<%- (1..entry.pages).each do |p| -%> <%- (1..entry.pages).each do |p| -%>
@@ -43,20 +89,38 @@
</select> </select>
</div> </div>
</div> </div>
<div class="uk-margin">
<label class="uk-form-label" for="mode-select">Mode</label>
<div class="uk-form-controls">
<select id="mode-select" class="uk-select">
<option value="continuous">Continuous</option>
<option value="paged">Paged</option>
</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" onclick="redirect('<%= exit_url %>')">Exit Reader</button> <button class="uk-button uk-button-danger" type="button" onclick="redirect('<%= exit_url %>')">Exit Reader</button>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
const base_url = "<%= base_url %>" const base_url = "<%= base_url %>";
const page = <%= page %>;
const tid = "<%= title.id %>";
const eid = "<%= entry.id %>";
</script> </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/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ScrollMagic/2.0.7/ScrollMagic.min.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.min.js"></script>
<script src="<%= base_url %>js/uikit-icons.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>
img[data-src][src*='data:image'] { background: white; }
#root img { width: 100%; }
</style>
</html> </html>
+1 -2
View File
@@ -117,8 +117,7 @@
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script> <%= render_component "dots-scripts" %>
<script src="<%= base_url %>js/dots.js"></script>
<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>
<script src="<%= base_url %>js/search.js"></script> <script src="<%= base_url %>js/search.js"></script>