Compare commits

...

73 Commits

Author SHA1 Message Date
Alex Ling f7b8e2d852 Bump version to v0.17.1 2020-12-27 09:46:14 +00:00
Alex Ling 946017c8bd Fix function redeclaration 2020-12-27 09:42:06 +00:00
Alex Ling ec5256dabd Improve batch mark UX (#97) 2020-12-27 09:42:06 +00:00
Alex Ling 4e707076a1 By default use the system theme setting (#111) 2020-12-27 09:42:06 +00:00
Alex Ling 66a3cc268b Merge branch 'master' into dev 2020-12-26 09:34:23 +00:00
Alex Ling 96949905b9 Cache entry display names
This improves the title page load time (#116)
2020-12-26 09:32:03 +00:00
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
54 changed files with 1464 additions and 540 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ 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
+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:
+1 -1
View File
@@ -1,4 +1,4 @@
FROM crystallang/crystal:0.34.0-alpine AS builder FROM crystallang/crystal:0.35.1-alpine AS builder
WORKDIR /Mango WORKDIR /Mango
+2 -2
View File
@@ -2,10 +2,10 @@ FROM arm32v7/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.34.0 && make deps && cd .. 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/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/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/ext/libwebp && git checkout v0.1.1 && make && cd ../stbi && make 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 . COPY mango-arm32v7.o .
+3 -3
View File
@@ -2,13 +2,13 @@ FROM arm64v8/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.34.0 && make deps && cd .. 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/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/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/ext/libwebp && git checkout v0.1.1 && make && cd ../stbi && make 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 . 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/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib RUN cc 'mango-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"] CMD ["./mango"]
+10 -3
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.12.0 Mango - Manga Server and Web Reader. Version 0.17.1
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
@@ -142,6 +148,7 @@ Mobile UI:
## Sponsors ## Sponsors
<a href="https://casinoshunter.com/online-casinos/"><img src="https://i.imgur.com/EJb3wBo.png" width="150" height="auto"></a> <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
+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"
} }
} }
+60 -32
View File
@@ -1,40 +1,68 @@
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);
};
/**
* 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 = 'system';
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 = 'system';
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'
}) })
+159 -27
View File
@@ -1,3 +1,7 @@
let lastSavedPage = page;
let items = [];
let longPages = false;
$(() => { $(() => {
getPages(); getPages();
@@ -5,17 +9,56 @@ $(() => {
const p = parseInt($('#page-select').val()); const p = parseInt($('#page-select').val());
toPage(p); toPage(p);
}); });
$('#mode-select').change(() => {
const mode = $('#mode-select').val();
const curIdx = parseInt($('#page-select').val());
updateMode(mode, curIdx);
});
});
$(window).resize(() => {
const mode = getProp('mode');
if (mode === 'continuous') return;
const wideScreen = $(window).width() > $(window).height();
const propMode = wideScreen ? 'height' : 'width';
setProp('mode', propMode);
}); });
/** /**
* Set an alpine.js property * Update the reader mode
* *
* @function setProp * @function updateMode
* @param {string} key - Key of the data property * @param {string} mode - The mode. Can be one of the followings:
* @param {*} prop - The data property * {'continuous', 'paged', 'height', 'width'}
* @param {number} targetPage - The one-based index of the target page
*/ */
const setProp = (key, prop) => { const updateMode = (mode, targetPage) => {
$('#root').get(0).__x.$data[key] = prop; 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();
});
}
waitForPage(targetPage, () => {
setTimeout(() => {
toPage(targetPage);
}, 100);
});
}; };
/** /**
@@ -28,7 +71,7 @@ const getPages = () => {
throw new Error(resp.error); throw new Error(resp.error);
const dimensions = data.dimensions; const dimensions = data.dimensions;
const items = dimensions.map((d, i) => { items = dimensions.map((d, i) => {
return { return {
id: i + 1, id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i+1}`, url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
@@ -37,13 +80,21 @@ const getPages = () => {
}; };
}); });
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('items', items);
setProp('loading', false); setProp('loading', false);
waitForPage(items.length, () => { const storedMode = localStorage.getItem('mode') || 'continuous';
toPage(page);
setupScroller(); setProp('mode', storedMode);
}); updateMode(storedMode, page);
$('#mode-select').val(storedMode);
}) })
.catch(e => { .catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`; const errMsg = `Failed to get the page dimensions. ${e}`;
@@ -60,7 +111,15 @@ const getPages = () => {
* @param {number} idx - One-based index of the page * @param {number} idx - One-based index of the page
*/ */
const toPage = (idx) => { const toPage = (idx) => {
const mode = getProp('mode');
if (mode === 'continuous') {
$(`#${idx}`).get(0).scrollIntoView(true); $(`#${idx}`).get(0).scrollIntoView(true);
} else {
if (idx >= 1 && idx <= items.length) {
setProp('curItem', items[idx - 1]);
}
}
replaceHistory(idx);
UIkit.modal($('#modal-sections')).hide(); UIkit.modal($('#modal-sections')).hide();
}; };
@@ -82,10 +141,9 @@ const waitForPage = (idx, cb) => {
* Show the control modal * Show the control modal
* *
* @function showControl * @function showControl
* @param {object} event - The onclick event that triggers the function * @param {string} idx - One-based index of the current page
*/ */
const showControl = (event) => { const showControl = (idx) => {
const idx = parseInt($(event.currentTarget).attr('id'));
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);
@@ -126,36 +184,110 @@ const replaceHistory = (idx) => {
* @function setupScroller * @function setupScroller
*/ */
const setupScroller = () => { const setupScroller = () => {
const mode = getProp('mode');
if (mode !== 'continuous') return;
$('#root img').each((idx, el) => { $('#root img').each((idx, el) => {
$(el).on('inview', (event, inView) => { $(el).on('inview', (event, inView) => {
if (inView) { if (inView) {
const current = $(event.currentTarget).attr('id'); const current = $(event.currentTarget).attr('id');
setProp('curItem', getProp('items')[current - 1]);
replaceHistory(current); replaceHistory(current);
} }
}); });
}); });
}; };
let lastSavedPage = page;
/** /**
* Update the backend reading progress if the current page is more than * Update the backend reading progress if:
* five pages away from the last saved page * 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 * @function saveProgress
* @param {number} idx - One-based index of the page * @param {number} idx - One-based index of the page
* @param {function} cb - Callback
*/ */
const saveProgress = (idx) => { const saveProgress = (idx, cb) => {
if (Math.abs(idx - lastSavedPage) < 5) return; idx = parseInt(idx);
if (Math.abs(idx - lastSavedPage) >= 5 ||
longPages ||
idx === 1 || idx === items.length
) {
lastSavedPage = idx; lastSavedPage = idx;
console.log('saving progress', idx);
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({entry: eid})}`; const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
$.post(url) $.ajax({
.then(data => { method: 'PUT',
if (data.error) throw new Error(data.error); url: url,
dataType: 'json'
}) })
.catch(e => { .done(data => {
console.error(e); if (data.error)
alert('danger', e); 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}`);
}); });
} };
+27 -19
View File
@@ -1,62 +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: http_proxy:
github: mamantoha/http_proxy git: https://github.com/mamantoha/http_proxy.git
version: 0.7.1 version: 0.7.1
image_size: image_size:
github: hkalexling/image_size.cr git: https://github.com/hkalexling/image_size.cr.git
version: 0.1.1 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
+5 -2
View File
@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.12.0 version: 0.17.1
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:
@@ -36,3 +37,5 @@ dependencies:
github: mamantoha/http_proxy github: mamantoha/http_proxy
image_size: image_size:
github: hkalexling/image_size.cr github: hkalexling/image_size.cr
koa:
github: hkalexling/koa
+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
+4 -2
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)]
+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
+33 -5
View File
@@ -47,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
@@ -69,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?
@@ -118,8 +117,8 @@ class Entry
"width" => size.width, "width" => size.width,
"height" => size.height, "height" => size.height,
} }
rescue rescue e
Logger.warn "Failed to read page #{i} of entry #{@id}" Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
sizes << {"width" => 1000_i32, "height" => 1000_i32} sizes << {"width" => 1000_i32, "height" => 1000_i32}
end end
end end
@@ -207,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
+78 -4
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
@@ -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
+10 -5
View File
@@ -3,7 +3,8 @@ require "../archive"
class Title class Title
property dir : String, parent_id : String, title_ids : Array(String), property dir : String, parent_id : String, title_ids : Array(String),
entries : Array(Entry), title : String, id : String, entries : Array(Entry), title : String, id : String,
encoded_title : String, mtime : Time encoded_title : String, mtime : Time,
entry_display_name_cache : Hash(String, String)?
def initialize(@dir : String, @parent_id, storage, def initialize(@dir : String, @parent_id, storage,
@library : Library) @library : Library)
@@ -56,7 +57,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
@@ -129,13 +130,17 @@ class Title
end end
def display_name(entry_name) def display_name(entry_name)
dn = entry_name unless @entry_display_name_cache
TitleInfo.new @dir do |info| TitleInfo.new @dir do |info|
info_dn = info.entry_display_name[entry_name]? @entry_display_name_cache = info.entry_display_name
end
end
dn = entry_name
info_dn = @entry_display_name_cache.not_nil![entry_name]?
unless info_dn.nil? || info_dn.empty? unless info_dn.nil? || info_dn.empty?
dn = info_dn dn = info_dn
end end
end
dn dn
end end
+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)
+14 -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)
@@ -69,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
@@ -91,6 +91,7 @@ module MangaDex
end end
channel.send page_job channel.send page_job
break unless @queue.exists? job
end end
end end
@@ -98,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
@@ -110,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"
+1 -1
View File
@@ -7,7 +7,7 @@ require "option_parser"
require "clim" require "clim"
require "./plugin/*" require "./plugin/*"
MANGO_VERSION = "0.12.0" MANGO_VERSION = "0.17.1"
# From http://www.network-science.de/ascii/ # From http://www.network-science.de/ascii/
BANNER = %{ BANNER = %{
+9 -1
View File
@@ -53,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?
@@ -66,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
@@ -109,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,
+15
View File
@@ -196,6 +196,21 @@ class Queue
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 MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
+357 -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
@@ -333,6 +641,10 @@ class APIRouter < Router
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| get "/api/dimensions/:tid/:eid" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
@@ -355,5 +667,33 @@ class APIRouter < Router
}.to_json }.to_json
end end
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
+1 -1
View File
@@ -12,7 +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
# 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
+57 -3
View File
@@ -35,9 +35,11 @@ class Storage
MainFiber.run do 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)"
@@ -243,6 +245,58 @@ class Storage
end 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 MainFiber.run do
unless @db.nil? unless @db.nil?
+3 -2
View File
@@ -5,7 +5,7 @@ require "http_proxy"
module HTTP module HTTP
class Client class Client
private def self.exec(uri : URI, tls : TLSContext = nil) private def self.exec(uri : URI, tls : TLSContext = nil)
Logger.debug "Using monkey-patched HTTP::Client" Logger.debug "Setting proxy"
previous_def uri, tls do |client, path| previous_def uri, tls do |client, path|
client.set_proxy get_proxy uri client.set_proxy get_proxy uri
yield client, path yield client, path
@@ -35,7 +35,8 @@ private def env_to_proxy(key : String) : HTTP::Proxy::Client?
begin begin
uri = URI.parse val uri = URI.parse val
HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil! HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil!,
username: uri.user, password: uri.password
rescue rescue
nil 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
+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>
+2 -1
View File
@@ -35,7 +35,7 @@
onclick="location='<%= base_url %>book/<%= item.id %>'" onclick="location='<%= base_url %>book/<%= item.id %>'"
<% end %>> <% end %>>
<div class="uk-card uk-card-default" x-data="{selected: false, hover: false, disabled: true}" :class="{selected: selected}" <div class="uk-card uk-card-default" x-data="{selected: false, hover: false, disabled: true, selecting: false}" :class="{selected: selected}" @count.window="selecting = $event.detail.count > 0"
<% if page == "title" && item.is_a?(Entry) && item.err_msg.nil? %> <% if page == "title" && item.is_a?(Entry) && item.err_msg.nil? %>
x-init="disabled = false" x-init="disabled = false"
<% end %>> <% end %>>
@@ -45,6 +45,7 @@
class="grayscale" class="grayscale"
<% end %>> <% end %>>
<div class="uk-overlay-primary uk-position-cover" x-show="!disabled && (selected || hover)"> <div class="uk-overlay-primary uk-position-cover" x-show="!disabled && (selected || hover)">
<div class="uk-height-1-1 uk-width-1-1" x-show="selecting" @click.stop="selected = !selected; $dispatch(selected ? 'add' : 'remove')"></div>
<div class="uk-position-center"> <div class="uk-position-center">
<span class="fas fa-check-circle fa-3x" @click.stop="selected = !selected; $dispatch(selected ? 'add' : 'remove')" :style="`color:${selected && 'orange'};`"></span> <span class="fas fa-check-circle fa-3x" @click.stop="selected = !selected; $dispatch(selected ? 'add' : 'remove')" :style="`color:${selected && 'orange'};`"></span>
</div> </div>
@@ -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>
+51 -15
View File
@@ -1,15 +1,11 @@
<div class="uk-margin"> <div id="root" x-data="{jobs: [], paused: undefined, loading: false, toggling: false}" x-init="load()">
<div id="actions" class="uk-margin"> <div class="uk-margin">
<button class="uk-button uk-button-default" onclick="remove()">Delete Completed Tasks</button> <button class="uk-button uk-button-default" @click="jobAction('delete')">Delete Completed Tasks</button>
<button class="uk-button uk-button-default" onclick="refresh()">Retry Failed Tasks</button> <button class="uk-button uk-button-default" @click="jobAction('retry')">Retry Failed Tasks</button>
<button class="uk-button uk-button-default" onclick="load()">Refresh Queue</button> <button class="uk-button uk-button-default" @click="load()" :disabled="loading">Refresh Queue</button>
<button class="uk-button uk-button-default" onclick="toggle()" id="pause-resume-btn" hidden></button> <button class="uk-button uk-button-default" x-show="paused !== undefined" x-text="paused ? 'Resume Download' : 'Pause Download'" @click="toggle()" :disabled="toggling"></button>
</div> </div>
<div id="config" class="uk-margin"> <table class="uk-table uk-table-striped uk-overflow-auto">
<label><input id="auto-refresh" class="uk-checkbox" type="checkbox" checked> Auto Refresh</label>
</div>
</div>
<table class="uk-table uk-table-striped uk-overflow-auto">
<thead> <thead>
<tr> <tr>
<th>Chapter</th> <th>Chapter</th>
@@ -21,12 +17,52 @@
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
</table> <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>
</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 %>
+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 %>
+1 -1
View File
@@ -29,7 +29,7 @@
<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 %>" />
+47 -10
View File
@@ -3,22 +3,34 @@
<%= 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"
<div class="uk-container uk-container-small"> id="root"
<div id="alert"></div> :style="mode === 'continuous' ? '' : 'padding:0'"
<div id="root" x-data="{ x-data="{
loading: true, loading: true,
mode: 'continuous', // can be 'continuous', 'height' or 'width'
msg: 'Loading the web reader. Please wait...', msg: 'Loading the web reader. Please wait...',
alertClass: 'uk-alert-primary', alertClass: 'uk-alert-primary',
items: [] items: [],
curItem: {},
flipAnimation: null
}"> }">
<div @keydown.window.debounce="keyHandler($event)"></div>
<div class="uk-container uk-container-small">
<div id="alert"></div>
<div x-show="loading"> <div x-show="loading">
<div :class="alertClass" x-show="msg" uk-alert> <div :class="alertClass" x-show="msg" uk-alert>
<p x-text="msg"></p> <p x-text="msg"></p>
</div> </div>
</div> </div>
<div x-show="!loading" x-cloak> </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"> <template x-for="item in items">
<img <img
uk-img uk-img
@@ -27,16 +39,32 @@
:width="item.width" :width="item.width"
:height="item.height" :height="item.height"
:id="item.id" :id="item.id"
@click="showControl($event)" :onclick="`showControl('${item.id}')`"
/> />
</template> </template>
<%- if next_entry_url -%> <%- if next_entry_url -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="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" @click="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 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>
</div> </div>
@@ -52,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| -%>
@@ -61,6 +89,15 @@
</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>
+2 -3
View File
@@ -1,5 +1,5 @@
<div> <div>
<div id="select-bar" class="uk-card uk-card-body uk-card-default uk-margin-bottom" uk-sticky="offset:10" x-data="{count: 0}" @add.window="count++" @remove.window="count--" x-show="count > 0" style="border:orange;border-style:solid;" x-cloak data-id="<%= title.id %>"> <div id="select-bar" class="uk-card uk-card-body uk-card-default uk-margin-bottom" uk-sticky="offset:10" x-data="{count: 0}" @add.window="count++; $dispatch('count', {count: count})" @remove.window="count--; $dispatch('count', {count: count})" x-show="count > 0" style="border:orange;border-style:solid;" x-cloak data-id="<%= title.id %>">
<div class="uk-child-width-1-3" uk-grid> <div class="uk-child-width-1-3" uk-grid>
<div> <div>
<p x-text="count + ' items selected'" style="color:orange"></p> <p x-text="count + ' items selected'" style="color:orange"></p>
@@ -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>