mirror of
https://github.com/hkalexling/Mango.git
synced 2026-01-25 00:00:36 -05:00
Compare commits
1 Commits
feature/lo
...
feature/di
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e65cd05ef1 |
@@ -1,120 +0,0 @@
|
|||||||
{
|
|
||||||
"projectName": "Mango",
|
|
||||||
"projectOwner": "hkalexling",
|
|
||||||
"repoType": "github",
|
|
||||||
"repoHost": "https://github.com",
|
|
||||||
"files": [
|
|
||||||
"README.md"
|
|
||||||
],
|
|
||||||
"imageSize": 100,
|
|
||||||
"commit": false,
|
|
||||||
"commitConvention": "none",
|
|
||||||
"contributors": [
|
|
||||||
{
|
|
||||||
"login": "hkalexling",
|
|
||||||
"name": "Alex Ling",
|
|
||||||
"avatar_url": "https://avatars1.githubusercontent.com/u/7845831?v=4",
|
|
||||||
"profile": "https://github.com/hkalexling/",
|
|
||||||
"contributions": [
|
|
||||||
"code",
|
|
||||||
"doc",
|
|
||||||
"infra"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "jaredlt",
|
|
||||||
"name": "jaredlt",
|
|
||||||
"avatar_url": "https://avatars1.githubusercontent.com/u/8590311?v=4",
|
|
||||||
"profile": "https://github.com/jaredlt",
|
|
||||||
"contributions": [
|
|
||||||
"code",
|
|
||||||
"ideas",
|
|
||||||
"design"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "shincurry",
|
|
||||||
"name": "ココロ",
|
|
||||||
"avatar_url": "https://avatars1.githubusercontent.com/u/4946624?v=4",
|
|
||||||
"profile": "https://windisco.com/",
|
|
||||||
"contributions": [
|
|
||||||
"infra"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "noirscape",
|
|
||||||
"name": "Valentijn",
|
|
||||||
"avatar_url": "https://avatars0.githubusercontent.com/u/13433513?v=4",
|
|
||||||
"profile": "https://catgirlsin.space/",
|
|
||||||
"contributions": [
|
|
||||||
"infra"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "flying-sausages",
|
|
||||||
"name": "flying-sausages",
|
|
||||||
"avatar_url": "https://avatars1.githubusercontent.com/u/23618693?v=4",
|
|
||||||
"profile": "https://github.com/flying-sausages",
|
|
||||||
"contributions": [
|
|
||||||
"doc",
|
|
||||||
"ideas"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "XavierSchiller",
|
|
||||||
"name": "Xavier",
|
|
||||||
"avatar_url": "https://avatars1.githubusercontent.com/u/22575255?v=4",
|
|
||||||
"profile": "https://github.com/XavierSchiller",
|
|
||||||
"contributions": [
|
|
||||||
"infra"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "WROIATE",
|
|
||||||
"name": "Jarao",
|
|
||||||
"avatar_url": "https://avatars3.githubusercontent.com/u/44677306?v=4",
|
|
||||||
"profile": "https://github.com/WROIATE",
|
|
||||||
"contributions": [
|
|
||||||
"infra"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "Leeingnyo",
|
|
||||||
"name": "이인용",
|
|
||||||
"avatar_url": "https://avatars0.githubusercontent.com/u/6760150?v=4",
|
|
||||||
"profile": "https://github.com/Leeingnyo",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "h45h74x",
|
|
||||||
"name": "Simon",
|
|
||||||
"avatar_url": "https://avatars1.githubusercontent.com/u/27204033?v=4",
|
|
||||||
"profile": "http://h45h74x.eu.org",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "davidkna",
|
|
||||||
"name": "David Knaack",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/835177?v=4",
|
|
||||||
"profile": "https://github.com/davidkna",
|
|
||||||
"contributions": [
|
|
||||||
"infra"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "lincolnthedev",
|
|
||||||
"name": "i use arch btw",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/41193328?v=4",
|
|
||||||
"profile": "https://lncn.dev",
|
|
||||||
"contributions": [
|
|
||||||
"infra"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"contributorsPerLine": 7,
|
|
||||||
"skipCi": true
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,3 @@ Lint/UnusedArgument:
|
|||||||
- src/routes/*
|
- src/routes/*
|
||||||
Metrics/CyclomaticComplexity:
|
Metrics/CyclomaticComplexity:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
Layout/LineLength:
|
|
||||||
Enabled: true
|
|
||||||
MaxLength: 80
|
|
||||||
Excluded:
|
|
||||||
- src/routes/api.cr
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
node_modules
|
|
||||||
lib
|
|
||||||
Dockerfile
|
|
||||||
Dockerfile.arm32v7
|
|
||||||
Dockerfile.arm64v8
|
|
||||||
README.md
|
|
||||||
.all-contributorsrc
|
|
||||||
env.example
|
|
||||||
.github/
|
|
||||||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,5 +1,3 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
open_collective: mango
|
|
||||||
patreon: hkalexling
|
patreon: hkalexling
|
||||||
ko_fi: hkalexling
|
|
||||||
|
|||||||
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -8,13 +8,10 @@ assignees: ''
|
|||||||
---
|
---
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
**Is your feature request related to a problem? Please describe.**
|
||||||
A clear and concise description of what the problem is. E.g. I'm always frustrated when [...]
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
**Describe the solution you'd like**
|
||||||
A clear and concise description of what you want to happen.
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
**Describe a small use-case for this feature request**
|
|
||||||
How would you imagine this to be used? What would be the advantage of this for the users of the application?
|
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context or screenshots about the feature request here.
|
Add any other context or screenshots about the feature request here.
|
||||||
|
|||||||
6
.github/autoapproval.yml
vendored
6
.github/autoapproval.yml
vendored
@@ -1,6 +0,0 @@
|
|||||||
from_owner:
|
|
||||||
- hkalexling
|
|
||||||
required_labels:
|
|
||||||
- autoapprove
|
|
||||||
apply_labels:
|
|
||||||
- autoapproved
|
|
||||||
24
.github/workflows/build.yml
vendored
24
.github/workflows/build.yml
vendored
@@ -2,39 +2,25 @@ name: Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ master, dev, hotfix/* ]
|
branches: [ master, dev ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ master, dev ]
|
branches: [ master, dev ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
if: "!contains(github.event.head_commit.message, 'skip ci')"
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: crystallang/crystal:1.0.0-alpine
|
image: crystallang/crystal:0.34.0-alpine
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: apk add --no-cache yarn yaml-static sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
run: apk add --no-cache yarn yaml sqlite-static
|
||||||
- name: Build
|
- name: Build
|
||||||
run: make static || make static
|
run: make
|
||||||
- name: Linter
|
- name: Linter
|
||||||
run: make check
|
run: make check
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: make test
|
run: make test
|
||||||
- name: Upload binary
|
|
||||||
uses: actions/upload-artifact@v2
|
|
||||||
with:
|
|
||||||
name: mango
|
|
||||||
path: mango
|
|
||||||
- name: build arm32v7 object file
|
|
||||||
run: make arm32v7 || make arm32v7
|
|
||||||
- name: build arm64v8 object file
|
|
||||||
run: make arm64v8 || make arm64v8
|
|
||||||
- name: Upload object files
|
|
||||||
uses: actions/upload-artifact@v2
|
|
||||||
with:
|
|
||||||
name: object files
|
|
||||||
path: ./*.o
|
|
||||||
|
|||||||
2
.github/workflows/dockerhub.yml
vendored
2
.github/workflows/dockerhub.yml
vendored
@@ -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 "RELEASE_VERSION=$(echo ${GITHUB_REF:10})" >> $GITHUB_ENV
|
run: echo ::set-env name=RELEASE_VERSION::$(echo ${GITHUB_REF:10})
|
||||||
- name: Publish to Dockerhub
|
- name: Publish to Dockerhub
|
||||||
uses: elgohr/Publish-Docker-Github-Action@master
|
uses: elgohr/Publish-Docker-Github-Action@master
|
||||||
with:
|
with:
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -8,9 +8,3 @@ yarn.lock
|
|||||||
dist
|
dist
|
||||||
mango
|
mango
|
||||||
.env
|
.env
|
||||||
*.md
|
|
||||||
public/css/uikit.css
|
|
||||||
public/img/*.svg
|
|
||||||
public/js/*.min.js
|
|
||||||
public/css/*.css
|
|
||||||
public/webfonts
|
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -1,15 +1,16 @@
|
|||||||
FROM crystallang/crystal:1.0.0-alpine AS builder
|
FROM crystallang/crystal:0.34.0-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /Mango
|
WORKDIR /Mango
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN apk add --no-cache yarn yaml-static sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
COPY package*.json .
|
||||||
RUN make static || make static
|
RUN apk add --no-cache yarn yaml sqlite-static \
|
||||||
|
&& make static
|
||||||
|
|
||||||
FROM library/alpine
|
FROM library/alpine
|
||||||
|
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
|
|
||||||
COPY --from=builder /Mango/mango /usr/local/bin/mango
|
COPY --from=builder /Mango/mango .
|
||||||
|
|
||||||
CMD ["/usr/local/bin/mango"]
|
CMD ["./mango"]
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
FROM arm32v7/ubuntu:18.04
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
|
||||||
|
|
||||||
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 1.0.0 && make deps && cd ..
|
|
||||||
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.8 && make && cd ..
|
|
||||||
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v1.0.0 && make && cd ..
|
|
||||||
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.5.0 && make && cd ..
|
|
||||||
|
|
||||||
COPY mango-arm32v7.o .
|
|
||||||
|
|
||||||
RUN cc 'mango-arm32v7.o' -o '/usr/local/bin/mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
|
||||||
|
|
||||||
CMD ["/usr/local/bin/mango"]
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
FROM arm64v8/ubuntu:18.04
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
|
||||||
|
|
||||||
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 1.0.0 && make deps && cd ..
|
|
||||||
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.8 && make && cd ..
|
|
||||||
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v1.0.0 && make && cd ..
|
|
||||||
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.5.0 && make && cd ..
|
|
||||||
|
|
||||||
COPY mango-arm64v8.o .
|
|
||||||
|
|
||||||
RUN cc 'mango-arm64v8.o' -o '/usr/local/bin/mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
|
||||||
|
|
||||||
CMD ["/usr/local/bin/mango"]
|
|
||||||
16
Makefile
16
Makefile
@@ -7,18 +7,14 @@ uglify:
|
|||||||
yarn
|
yarn
|
||||||
yarn uglify
|
yarn uglify
|
||||||
|
|
||||||
setup: libs
|
|
||||||
yarn
|
|
||||||
yarn gulp dev
|
|
||||||
|
|
||||||
build: libs
|
build: libs
|
||||||
crystal build src/mango.cr --release --progress --error-trace
|
crystal build src/mango.cr --release --progress
|
||||||
|
|
||||||
static: uglify | libs
|
static: uglify | libs
|
||||||
crystal build src/mango.cr --release --progress --static --error-trace
|
crystal build src/mango.cr --release --progress --static
|
||||||
|
|
||||||
libs:
|
libs:
|
||||||
shards install --production
|
shards install
|
||||||
|
|
||||||
run:
|
run:
|
||||||
crystal run src/mango.cr --error-trace
|
crystal run src/mango.cr --error-trace
|
||||||
@@ -30,12 +26,6 @@ check:
|
|||||||
crystal tool format --check
|
crystal tool format --check
|
||||||
./bin/ameba
|
./bin/ameba
|
||||||
|
|
||||||
arm32v7:
|
|
||||||
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7
|
|
||||||
|
|
||||||
arm64v8:
|
|
||||||
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='aarch64-linux-gnu' -o mango-arm64v8
|
|
||||||
|
|
||||||
install:
|
install:
|
||||||
cp mango $(INSTALL_DIR)/mango
|
cp mango $(INSTALL_DIR)/mango
|
||||||
|
|
||||||
|
|||||||
96
README.md
96
README.md
@@ -1,24 +1,23 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# Mango
|
# Mango
|
||||||
|
|
||||||
[](https://www.patreon.com/hkalexling)  [](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [](http://discord.com/invite/ezKtacCp9Q)
|
[](https://www.patreon.com/hkalexling)  [](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
|
||||||
|
|
||||||
Mango is a self-hosted manga server and reader. Its features include
|
Mango is a self-hosted manga server and reader. Its features include
|
||||||
|
|
||||||
- Multi-user support
|
- Multi-user support
|
||||||
- OPDS support
|
|
||||||
- Dark/light mode switch
|
- Dark/light mode switch
|
||||||
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar`
|
- Supports both `.zip` and `.cbz` formats
|
||||||
- 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
|
||||||
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites
|
|
||||||
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
||||||
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
||||||
|
|
||||||
Please check the [Wiki](https://github.com/hkalexling/Mango/wiki) for more information.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Pre-built Binary
|
### Pre-built Binary
|
||||||
@@ -40,7 +39,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
|||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
1. Make sure you have `crystal`, `shards` and `yarn` installed. You might also need to install the development headers of some libraries. Please see the [Dockerfile](https://github.com/hkalexling/Mango/blob/master/Dockerfile) for the full list of dependencies
|
1. Make sure you have `crystal`, `shards` and `yarn` installed. You might also need to install the development headers for `libsqlite3` and `libyaml`.
|
||||||
2. Clone the repository
|
2. Clone the repository
|
||||||
3. `make && sudo make install`
|
3. `make && sudo make install`
|
||||||
4. Start Mango by running the command `mango`
|
4. Start Mango by running the command `mango`
|
||||||
@@ -51,21 +50,11 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
|||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
```
|
```
|
||||||
Mango - Manga Server and Web Reader. Version 0.24.0
|
Mango e-manga server/reader. Version 0.3.0
|
||||||
|
|
||||||
Usage:
|
-v, --version Show version
|
||||||
|
-h, --help Show help
|
||||||
mango [sub_command] [options]
|
-c PATH, --config=PATH Path to the config file. Default is `~/.config/mango/config.yml`
|
||||||
|
|
||||||
Options:
|
|
||||||
|
|
||||||
-c PATH, --config=PATH Path to the config file [type:String]
|
|
||||||
-h, --help Show this help.
|
|
||||||
-v, --version Show version.
|
|
||||||
|
|
||||||
Sub Commands:
|
|
||||||
|
|
||||||
admin Run admin tools
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Config
|
### Config
|
||||||
@@ -74,43 +63,26 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
host: 0.0.0.0
|
|
||||||
port: 9000
|
port: 9000
|
||||||
base_url: /
|
library_path: /home/alex_ling/mango/library
|
||||||
session_secret: mango-session-secret
|
upload_path: /home/alex_ling/mango/uploads
|
||||||
library_path: ~/mango/library
|
db_path: /home/alex_ling/mango/mango.db
|
||||||
db_path: ~/mango/mango.db
|
|
||||||
scan_interval_minutes: 5
|
scan_interval_minutes: 5
|
||||||
thumbnail_generation_interval_hours: 24
|
|
||||||
log_level: info
|
log_level: info
|
||||||
upload_path: ~/mango/uploads
|
|
||||||
plugin_path: ~/mango/plugins
|
|
||||||
download_timeout_seconds: 30
|
|
||||||
library_cache_path: ~/mango/library.yml.gz
|
|
||||||
cache_enabled: false
|
|
||||||
cache_size_mbs: 50
|
|
||||||
cache_log_enabled: true
|
|
||||||
disable_login: false
|
|
||||||
default_username: ""
|
|
||||||
auth_proxy_header_name: ""
|
|
||||||
mangadex:
|
mangadex:
|
||||||
base_url: https://mangadex.org
|
base_url: https://mangadex.org
|
||||||
api_url: https://api.mangadex.org/v2
|
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}'
|
|
||||||
manga_rename_rule: '{title}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
- `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan
|
||||||
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
|
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
|
||||||
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
|
|
||||||
- By setting `cache_enabled` to `true`, you can enable an experimental feature where Mango caches library metadata to improve page load time. You can further fine-tune the feature with `cache_size_mbs` and `cache_log_enabled`.
|
|
||||||
|
|
||||||
### Library Structure
|
### Library Structure
|
||||||
|
|
||||||
You can organize your archive files in nested folders in the library directory. Here's an example:
|
You can organize your `.cbz/.zip` files in nested folders in the library directory. Here's an example:
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
@@ -153,36 +125,6 @@ Mobile UI:
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Sponsors
|
|
||||||
|
|
||||||
<a href="https://casinoshunter.com/online-casinos/"><img src="https://i.imgur.com/EJb3wBo.png" width="150" height="auto"></a>
|
|
||||||
<a href="https://www.browserstack.com/open-source"><img src="https://i.imgur.com/hGJUJXD.png" width="150" height="auto"></a>
|
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interested in code contributions.
|
[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/0)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/1)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/2)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/3)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/4)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/5)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/6)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/7)
|
||||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
|
||||||
<!-- prettier-ignore-start -->
|
|
||||||
<!-- markdownlint-disable -->
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td align="center"><a href="https://github.com/hkalexling/"><img src="https://avatars1.githubusercontent.com/u/7845831?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Ling</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Code">💻</a> <a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Documentation">📖</a> <a href="#infra-hkalexling" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
|
||||||
<td align="center"><a href="https://github.com/jaredlt"><img src="https://avatars1.githubusercontent.com/u/8590311?v=4?s=100" width="100px;" alt=""/><br /><sub><b>jaredlt</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=jaredlt" title="Code">💻</a> <a href="#ideas-jaredlt" title="Ideas, Planning, & Feedback">🤔</a> <a href="#design-jaredlt" title="Design">🎨</a></td>
|
|
||||||
<td align="center"><a href="https://windisco.com/"><img src="https://avatars1.githubusercontent.com/u/4946624?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ココロ</b></sub></a><br /><a href="#infra-shincurry" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
|
||||||
<td align="center"><a href="https://catgirlsin.space/"><img src="https://avatars0.githubusercontent.com/u/13433513?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Valentijn</b></sub></a><br /><a href="#infra-noirscape" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
|
||||||
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" width="100px;" alt=""/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=flying-sausages" title="Documentation">📖</a> <a href="#ideas-flying-sausages" title="Ideas, Planning, & Feedback">🤔</a></td>
|
|
||||||
<td align="center"><a href="https://github.com/XavierSchiller"><img src="https://avatars1.githubusercontent.com/u/22575255?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Xavier</b></sub></a><br /><a href="#infra-XavierSchiller" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
|
||||||
<td align="center"><a href="https://github.com/WROIATE"><img src="https://avatars3.githubusercontent.com/u/44677306?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jarao</b></sub></a><br /><a href="#infra-WROIATE" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td>
|
|
||||||
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">💻</a></td>
|
|
||||||
<td align="center"><a href="https://github.com/davidkna"><img src="https://avatars.githubusercontent.com/u/835177?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David Knaack</b></sub></a><br /><a href="#infra-davidkna" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
|
||||||
<td align="center"><a href="https://lncn.dev"><img src="https://avatars.githubusercontent.com/u/41193328?v=4?s=100" width="100px;" alt=""/><br /><sub><b>i use arch btw</b></sub></a><br /><a href="#infra-lincolnthedev" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- markdownlint-restore -->
|
|
||||||
<!-- prettier-ignore-end -->
|
|
||||||
|
|
||||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
|
||||||
|
|||||||
69
gulpfile.js
69
gulpfile.js
@@ -1,74 +1,29 @@
|
|||||||
const gulp = require('gulp');
|
const gulp = require('gulp');
|
||||||
const babel = require('gulp-babel');
|
const minify = require("gulp-babel-minify");
|
||||||
const minify = require('gulp-babel-minify');
|
|
||||||
const minifyCss = require('gulp-minify-css');
|
const minifyCss = require('gulp-minify-css');
|
||||||
const less = require('gulp-less');
|
|
||||||
|
|
||||||
gulp.task('copy-img', () => {
|
gulp.task('minify-js', () => {
|
||||||
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
|
return gulp.src('public/js/*.js')
|
||||||
.pipe(gulp.dest('public/img'));
|
|
||||||
});
|
|
||||||
|
|
||||||
gulp.task('copy-font', () => {
|
|
||||||
return gulp.src('node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff**')
|
|
||||||
.pipe(gulp.dest('public/webfonts'));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Copy files from node_modules
|
|
||||||
gulp.task('node-modules-copy', gulp.parallel('copy-img', 'copy-font'));
|
|
||||||
|
|
||||||
// Compile less
|
|
||||||
gulp.task('less', () => {
|
|
||||||
return gulp.src([
|
|
||||||
'public/css/mango.less',
|
|
||||||
'public/css/tags.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
|
|
||||||
}))
|
}))
|
||||||
.pipe(gulp.dest('dist/js'));
|
.pipe(gulp.dest('dist/js'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Minify CSS and output to dist
|
|
||||||
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'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Copy static files (includeing images) to dist
|
gulp.task('img', () => {
|
||||||
gulp.task('copy-files', () => {
|
return gulp.src('public/img/*')
|
||||||
return gulp.src([
|
.pipe(gulp.dest('dist/img'));
|
||||||
'public/*.*',
|
});
|
||||||
'public/img/*',
|
|
||||||
'public/webfonts/*',
|
gulp.task('favicon', () => {
|
||||||
'public/js/*.min.js'
|
return gulp.src('public/favicon.ico')
|
||||||
], {
|
|
||||||
base: 'public'
|
|
||||||
})
|
|
||||||
.pipe(gulp.dest('dist'));
|
.pipe(gulp.dest('dist'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up the public folder for development
|
gulp.task('default', gulp.parallel('minify-js', 'minify-css', 'img', 'favicon'));
|
||||||
gulp.task('dev', gulp.parallel('node-modules-copy', 'less'));
|
|
||||||
|
|
||||||
// Set up the dist folder for deployment
|
|
||||||
gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));
|
|
||||||
|
|
||||||
// Default task
|
|
||||||
gulp.task('default', gulp.series('dev', 'deploy'));
|
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
class ForeignKeys < MG::Base
|
|
||||||
def up : String
|
|
||||||
<<-SQL
|
|
||||||
-- add foreign key to tags
|
|
||||||
ALTER TABLE tags RENAME TO tmp;
|
|
||||||
|
|
||||||
CREATE TABLE tags (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
tag TEXT NOT NULL,
|
|
||||||
UNIQUE (id, tag),
|
|
||||||
FOREIGN KEY (id) REFERENCES titles (id)
|
|
||||||
ON UPDATE CASCADE
|
|
||||||
ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO tags
|
|
||||||
SELECT * FROM tmp;
|
|
||||||
|
|
||||||
DROP TABLE tmp;
|
|
||||||
|
|
||||||
CREATE INDEX tags_id_idx ON tags (id);
|
|
||||||
CREATE INDEX tags_tag_idx ON tags (tag);
|
|
||||||
|
|
||||||
-- add foreign key to thumbnails
|
|
||||||
ALTER TABLE thumbnails RENAME TO tmp;
|
|
||||||
|
|
||||||
CREATE TABLE thumbnails (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
data BLOB NOT NULL,
|
|
||||||
filename TEXT NOT NULL,
|
|
||||||
mime TEXT NOT NULL,
|
|
||||||
size INTEGER NOT NULL,
|
|
||||||
FOREIGN KEY (id) REFERENCES ids (id)
|
|
||||||
ON UPDATE CASCADE
|
|
||||||
ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO thumbnails
|
|
||||||
SELECT * FROM tmp;
|
|
||||||
|
|
||||||
DROP TABLE tmp;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
|
|
||||||
def down : String
|
|
||||||
<<-SQL
|
|
||||||
-- remove foreign key from thumbnails
|
|
||||||
ALTER TABLE thumbnails RENAME TO tmp;
|
|
||||||
|
|
||||||
CREATE TABLE thumbnails (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
data BLOB NOT NULL,
|
|
||||||
filename TEXT NOT NULL,
|
|
||||||
mime TEXT NOT NULL,
|
|
||||||
size INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO thumbnails
|
|
||||||
SELECT * FROM tmp;
|
|
||||||
|
|
||||||
DROP TABLE tmp;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
|
|
||||||
|
|
||||||
-- remove foreign key from tags
|
|
||||||
ALTER TABLE tags RENAME TO tmp;
|
|
||||||
|
|
||||||
CREATE TABLE tags (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
tag TEXT NOT NULL,
|
|
||||||
UNIQUE (id, tag)
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO tags
|
|
||||||
SELECT * FROM tmp;
|
|
||||||
|
|
||||||
DROP TABLE tmp;
|
|
||||||
|
|
||||||
CREATE INDEX tags_id_idx ON tags (id);
|
|
||||||
CREATE INDEX tags_tag_idx ON tags (tag);
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
class CreateIds < MG::Base
|
|
||||||
def up : String
|
|
||||||
<<-SQL
|
|
||||||
CREATE TABLE IF NOT EXISTS ids (
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
is_title INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS path_idx ON ids (path);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS id_idx ON ids (id);
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
|
|
||||||
def down : String
|
|
||||||
<<-SQL
|
|
||||||
DROP TABLE ids;
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
class IDSignature < MG::Base
|
|
||||||
def up : String
|
|
||||||
<<-SQL
|
|
||||||
ALTER TABLE ids ADD COLUMN signature TEXT;
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
|
|
||||||
def down : String
|
|
||||||
<<-SQL
|
|
||||||
-- remove signature column from ids
|
|
||||||
ALTER TABLE ids RENAME TO tmp;
|
|
||||||
|
|
||||||
CREATE TABLE ids (
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
id TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO ids
|
|
||||||
SELECT path, id
|
|
||||||
FROM tmp;
|
|
||||||
|
|
||||||
DROP TABLE tmp;
|
|
||||||
|
|
||||||
-- recreate the indices
|
|
||||||
CREATE UNIQUE INDEX path_idx ON ids (path);
|
|
||||||
CREATE UNIQUE INDEX id_idx ON ids (id);
|
|
||||||
|
|
||||||
-- recreate the foreign key constraint on thumbnails
|
|
||||||
ALTER TABLE thumbnails RENAME TO tmp;
|
|
||||||
|
|
||||||
CREATE TABLE thumbnails (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
data BLOB NOT NULL,
|
|
||||||
filename TEXT NOT NULL,
|
|
||||||
mime TEXT NOT NULL,
|
|
||||||
size INTEGER NOT NULL,
|
|
||||||
FOREIGN KEY (id) REFERENCES ids (id)
|
|
||||||
ON UPDATE CASCADE
|
|
||||||
ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO thumbnails
|
|
||||||
SELECT * FROM tmp;
|
|
||||||
|
|
||||||
DROP TABLE tmp;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
class CreateMangaDexAccount < MG::Base
|
|
||||||
def up : String
|
|
||||||
<<-SQL
|
|
||||||
CREATE TABLE md_account (
|
|
||||||
username TEXT NOT NULL PRIMARY KEY,
|
|
||||||
token TEXT NOT NULL,
|
|
||||||
expire INTEGER NOT NULL,
|
|
||||||
FOREIGN KEY (username) REFERENCES users (username)
|
|
||||||
ON UPDATE CASCADE
|
|
||||||
ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
|
|
||||||
def down : String
|
|
||||||
<<-SQL
|
|
||||||
DROP TABLE md_account;
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
class RelativePath < MG::Base
|
|
||||||
def up : String
|
|
||||||
base = Config.current.library_path
|
|
||||||
# Escape single quotes in case the path contains them, and remove the
|
|
||||||
# trailing slash (this is a mistake, fixed in DB version 10)
|
|
||||||
base = base.gsub("'", "''").rstrip "/"
|
|
||||||
|
|
||||||
<<-SQL
|
|
||||||
-- update the path column in ids to relative paths
|
|
||||||
UPDATE ids
|
|
||||||
SET path = REPLACE(path, '#{base}', '');
|
|
||||||
|
|
||||||
-- update the path column in titles to relative paths
|
|
||||||
UPDATE titles
|
|
||||||
SET path = REPLACE(path, '#{base}', '');
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
|
|
||||||
def down : String
|
|
||||||
base = Config.current.library_path
|
|
||||||
base = base.gsub("'", "''").rstrip "/"
|
|
||||||
|
|
||||||
<<-SQL
|
|
||||||
-- update the path column in ids to absolute paths
|
|
||||||
UPDATE ids
|
|
||||||
SET path = '#{base}' || path;
|
|
||||||
|
|
||||||
-- update the path column in titles to absolute paths
|
|
||||||
UPDATE titles
|
|
||||||
SET path = '#{base}' || path;
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# In DB version 8, we replaced the absolute paths in DB with relative paths,
|
|
||||||
# but we mistakenly left the starting slashes. This migration removes them.
|
|
||||||
class RelativePathFix < MG::Base
|
|
||||||
def up : String
|
|
||||||
<<-SQL
|
|
||||||
-- remove leading slashes from the paths in ids
|
|
||||||
UPDATE ids
|
|
||||||
SET path = SUBSTR(path, 2, LENGTH(path) - 1)
|
|
||||||
WHERE path LIKE '/%';
|
|
||||||
|
|
||||||
-- remove leading slashes from the paths in titles
|
|
||||||
UPDATE titles
|
|
||||||
SET path = SUBSTR(path, 2, LENGTH(path) - 1)
|
|
||||||
WHERE path LIKE '/%';
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
|
|
||||||
def down : String
|
|
||||||
<<-SQL
|
|
||||||
-- add leading slashes to paths in ids
|
|
||||||
UPDATE ids
|
|
||||||
SET path = '/' || path
|
|
||||||
WHERE path NOT LIKE '/%';
|
|
||||||
|
|
||||||
-- add leading slashes to paths in titles
|
|
||||||
UPDATE titles
|
|
||||||
SET path = '/' || path
|
|
||||||
WHERE path NOT LIKE '/%';
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
class CreateTags < MG::Base
|
|
||||||
def up : String
|
|
||||||
<<-SQL
|
|
||||||
CREATE TABLE IF NOT EXISTS tags (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
tag TEXT NOT NULL,
|
|
||||||
UNIQUE (id, tag)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS tags_id_idx ON tags (id);
|
|
||||||
CREATE INDEX IF NOT EXISTS tags_tag_idx ON tags (tag);
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
|
|
||||||
def down : String
|
|
||||||
<<-SQL
|
|
||||||
DROP TABLE tags;
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
class CreateThumbnails < MG::Base
|
|
||||||
def up : String
|
|
||||||
<<-SQL
|
|
||||||
CREATE TABLE IF NOT EXISTS thumbnails (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
data BLOB NOT NULL,
|
|
||||||
filename TEXT NOT NULL,
|
|
||||||
mime TEXT NOT NULL,
|
|
||||||
size INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS tn_index ON thumbnails (id);
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
|
|
||||||
def down : String
|
|
||||||
<<-SQL
|
|
||||||
DROP TABLE thumbnails;
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
class CreateTitles < MG::Base
|
|
||||||
def up : String
|
|
||||||
<<-SQL
|
|
||||||
-- create titles
|
|
||||||
CREATE TABLE titles (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
signature TEXT
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX titles_id_idx on titles (id);
|
|
||||||
CREATE UNIQUE INDEX titles_path_idx on titles (path);
|
|
||||||
|
|
||||||
-- migrate data from ids to titles
|
|
||||||
INSERT INTO titles
|
|
||||||
SELECT id, path, null
|
|
||||||
FROM ids
|
|
||||||
WHERE is_title = 1;
|
|
||||||
|
|
||||||
DELETE FROM ids
|
|
||||||
WHERE is_title = 1;
|
|
||||||
|
|
||||||
-- remove the is_title column from ids
|
|
||||||
ALTER TABLE ids RENAME TO tmp;
|
|
||||||
|
|
||||||
CREATE TABLE ids (
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
id TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO ids
|
|
||||||
SELECT path, id
|
|
||||||
FROM tmp;
|
|
||||||
|
|
||||||
DROP TABLE tmp;
|
|
||||||
|
|
||||||
-- recreate the indices
|
|
||||||
CREATE UNIQUE INDEX path_idx ON ids (path);
|
|
||||||
CREATE UNIQUE INDEX id_idx ON ids (id);
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
|
|
||||||
def down : String
|
|
||||||
<<-SQL
|
|
||||||
-- insert the is_title column
|
|
||||||
ALTER TABLE ids ADD COLUMN is_title INTEGER NOT NULL DEFAULT 0;
|
|
||||||
|
|
||||||
-- migrate data from titles to ids
|
|
||||||
INSERT INTO ids
|
|
||||||
SELECT path, id, 1
|
|
||||||
FROM titles;
|
|
||||||
|
|
||||||
-- remove titles
|
|
||||||
DROP TABLE titles;
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
class UnavailableIDs < MG::Base
|
|
||||||
def up : String
|
|
||||||
<<-SQL
|
|
||||||
-- add unavailable column to ids
|
|
||||||
ALTER TABLE ids ADD COLUMN unavailable INTEGER NOT NULL DEFAULT 0;
|
|
||||||
|
|
||||||
-- add unavailable column to titles
|
|
||||||
ALTER TABLE titles ADD COLUMN unavailable INTEGER NOT NULL DEFAULT 0;
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
|
|
||||||
def down : String
|
|
||||||
<<-SQL
|
|
||||||
-- remove unavailable column from ids
|
|
||||||
ALTER TABLE ids RENAME TO tmp;
|
|
||||||
|
|
||||||
CREATE TABLE ids (
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
signature TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO ids
|
|
||||||
SELECT path, id, signature
|
|
||||||
FROM tmp;
|
|
||||||
|
|
||||||
DROP TABLE tmp;
|
|
||||||
|
|
||||||
-- recreate the indices
|
|
||||||
CREATE UNIQUE INDEX path_idx ON ids (path);
|
|
||||||
CREATE UNIQUE INDEX id_idx ON ids (id);
|
|
||||||
|
|
||||||
-- recreate the foreign key constraint on thumbnails
|
|
||||||
ALTER TABLE thumbnails RENAME TO tmp;
|
|
||||||
|
|
||||||
CREATE TABLE thumbnails (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
data BLOB NOT NULL,
|
|
||||||
filename TEXT NOT NULL,
|
|
||||||
mime TEXT NOT NULL,
|
|
||||||
size INTEGER NOT NULL,
|
|
||||||
FOREIGN KEY (id) REFERENCES ids (id)
|
|
||||||
ON UPDATE CASCADE
|
|
||||||
ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO thumbnails
|
|
||||||
SELECT * FROM tmp;
|
|
||||||
|
|
||||||
DROP TABLE tmp;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
|
|
||||||
|
|
||||||
-- remove unavailable column from titles
|
|
||||||
ALTER TABLE titles RENAME TO tmp;
|
|
||||||
|
|
||||||
CREATE TABLE titles (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
signature TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO titles
|
|
||||||
SELECT path, id, signature
|
|
||||||
FROM tmp;
|
|
||||||
|
|
||||||
DROP TABLE tmp;
|
|
||||||
|
|
||||||
-- recreate the indices
|
|
||||||
CREATE UNIQUE INDEX titles_id_idx on titles (id);
|
|
||||||
CREATE UNIQUE INDEX titles_path_idx on titles (path);
|
|
||||||
|
|
||||||
-- recreate the foreign key constraint on tags
|
|
||||||
ALTER TABLE tags RENAME TO tmp;
|
|
||||||
|
|
||||||
CREATE TABLE tags (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
tag TEXT NOT NULL,
|
|
||||||
UNIQUE (id, tag),
|
|
||||||
FOREIGN KEY (id) REFERENCES titles (id)
|
|
||||||
ON UPDATE CASCADE
|
|
||||||
ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO tags
|
|
||||||
SELECT * FROM tmp;
|
|
||||||
|
|
||||||
DROP TABLE tmp;
|
|
||||||
|
|
||||||
CREATE INDEX tags_id_idx ON tags (id);
|
|
||||||
CREATE INDEX tags_tag_idx ON tags (tag);
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
class CreateUsers < MG::Base
|
|
||||||
def up : String
|
|
||||||
<<-SQL
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
username TEXT NOT NULL,
|
|
||||||
password TEXT NOT NULL,
|
|
||||||
token TEXT,
|
|
||||||
admin INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS username_idx ON users (username);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS token_idx ON users (token);
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
|
|
||||||
def down : String
|
|
||||||
<<-SQL
|
|
||||||
DROP TABLE users;
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
end
|
|
||||||
11
package.json
11
package.json
@@ -6,20 +6,11 @@
|
|||||||
"author": "Alex Ling <hkalexling@gmail.com>",
|
"author": "Alex Ling <hkalexling@gmail.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/preset-env": "^7.11.5",
|
|
||||||
"all-contributors-cli": "^6.19.0",
|
|
||||||
"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-minify-css": "^1.2.4"
|
||||||
"gulp-minify-css": "^1.2.4",
|
|
||||||
"less": "^3.11.3"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"uglify": "gulp"
|
"uglify": "gulp"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@fortawesome/fontawesome-free": "^5.14.0",
|
|
||||||
"uikit": "^3.5.4"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
74
public/css/mango.css
Normal file
74
public/css/mango.css
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
.uk-alert-close {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
.uk-card-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.uk-card-media-top {
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.uk-card-media-top {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.uk-card-media-top > img {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.uk-card-title {
|
||||||
|
height: 3em;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.acard:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.uk-list li {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.reader-bg {
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
#scan-status {
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
.break-word {
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.uk-logo > img {
|
||||||
|
max-height: 90px;
|
||||||
|
}
|
||||||
|
.uk-search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#selectable .ui-selecting {
|
||||||
|
background: #EEE6B9;
|
||||||
|
}
|
||||||
|
#selectable .ui-selected {
|
||||||
|
background: #F4E487;
|
||||||
|
}
|
||||||
|
#selectable .ui-selecting.dark {
|
||||||
|
background: #5E5731;
|
||||||
|
}
|
||||||
|
#selectable .ui-selected.dark {
|
||||||
|
background: #9D9252;
|
||||||
|
}
|
||||||
|
td > .uk-dropdown {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
#edit-modal .uk-grid > div {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
#edit-modal #cover {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
#edit-modal #cover-upload {
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
#edit-modal .uk-modal-body .uk-inline {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
// UIKit
|
|
||||||
@import "./uikit.less";
|
|
||||||
|
|
||||||
// FontAwesome
|
|
||||||
@import "../../node_modules/@fortawesome/fontawesome-free/less/fontawesome.less";
|
|
||||||
@import "../../node_modules/@fortawesome/fontawesome-free/less/solid.less";
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
src: url('@{fa-font-path}/fa-solid-900.woff2');
|
|
||||||
src: url('@{fa-font-path}/fa-solid-900.woff2') format('woff2'),
|
|
||||||
url('@{fa-font-path}/fa-solid-900.woff') format('woff');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Item cards
|
|
||||||
.item .uk-card {
|
|
||||||
cursor: pointer;
|
|
||||||
.uk-card-media-top {
|
|
||||||
width: 100%;
|
|
||||||
height: 250px;
|
|
||||||
@media (min-width: 600px) {
|
|
||||||
height: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
|
|
||||||
&.grayscale {
|
|
||||||
filter: grayscale(100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.uk-card-body {
|
|
||||||
padding: 20px;
|
|
||||||
.uk-card-title {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
.uk-card-title:not(.free-height) {
|
|
||||||
max-height: 3em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// jQuery selectable
|
|
||||||
#selectable {
|
|
||||||
.ui-selecting {
|
|
||||||
background: #EEE6B9;
|
|
||||||
}
|
|
||||||
.ui-selected {
|
|
||||||
background: #F4E487;
|
|
||||||
}
|
|
||||||
.uk-light & {
|
|
||||||
.ui-selecting {
|
|
||||||
background: #5E5731;
|
|
||||||
}
|
|
||||||
.ui-selected {
|
|
||||||
background: #9D9252;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit modal
|
|
||||||
#edit-modal {
|
|
||||||
.uk-grid > div {
|
|
||||||
height: 300px;
|
|
||||||
}
|
|
||||||
#cover {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
#cover-upload {
|
|
||||||
height: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.uk-modal-body .uk-inline {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dark theme
|
|
||||||
.uk-light {
|
|
||||||
.uk-modal-header,
|
|
||||||
.uk-modal-body,
|
|
||||||
.uk-modal-footer {
|
|
||||||
background: #222;
|
|
||||||
}
|
|
||||||
.uk-navbar-dropdown,
|
|
||||||
.uk-dropdown {
|
|
||||||
color: #ccc;
|
|
||||||
background: #333;
|
|
||||||
}
|
|
||||||
.uk-nav-header,
|
|
||||||
.uk-description-list > dt {
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alpine magic
|
|
||||||
[x-cloak] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch select bar on title page
|
|
||||||
#select-bar-controls {
|
|
||||||
a {
|
|
||||||
transform: scale(1.5, 1.5);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: orange;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Totop button
|
|
||||||
#totop-wrapper {
|
|
||||||
position: absolute;
|
|
||||||
top: 100vh;
|
|
||||||
right: 2em;
|
|
||||||
bottom: 0;
|
|
||||||
|
|
||||||
a {
|
|
||||||
position: fixed;
|
|
||||||
position: sticky;
|
|
||||||
top: calc(100vh - 5em);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Misc
|
|
||||||
.uk-alert-close {
|
|
||||||
color: black !important;
|
|
||||||
}
|
|
||||||
.break-word {
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
.uk-search {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
@light-gray: #e5e5e5;
|
|
||||||
@gray: #666666;
|
|
||||||
@black: #141414;
|
|
||||||
@blue: rgb(30, 135, 240);
|
|
||||||
@white1: rgba(255, 255, 255, .1);
|
|
||||||
@white2: rgba(255, 255, 255, .2);
|
|
||||||
@white7: rgba(255, 255, 255, .7);
|
|
||||||
|
|
||||||
.select2-container--default {
|
|
||||||
.select2-selection--multiple {
|
|
||||||
border: 1px solid @light-gray;
|
|
||||||
.select2-selection__choice,
|
|
||||||
.select2-selection__choice__remove,
|
|
||||||
.select2-selection__choice__remove:hover
|
|
||||||
{
|
|
||||||
background-color: @blue;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.select2-dropdown {
|
|
||||||
.select2-results__option--highlighted.select2-results__option--selectable {
|
|
||||||
background-color: @blue;
|
|
||||||
}
|
|
||||||
.select2-results__option--selected:not(.select2-results__option--highlighted) {
|
|
||||||
background-color: @light-gray
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-light {
|
|
||||||
.select2-container--default {
|
|
||||||
.select2-selection {
|
|
||||||
background-color: @white1;
|
|
||||||
}
|
|
||||||
.select2-selection--multiple {
|
|
||||||
border: 1px solid @white2;
|
|
||||||
.select2-selection__choice,
|
|
||||||
.select2-selection__choice__remove,
|
|
||||||
.select2-selection__choice__remove:hover
|
|
||||||
{
|
|
||||||
background-color: white;
|
|
||||||
color: @gray;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.select2-search__field {
|
|
||||||
color: @white7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.select2-dropdown {
|
|
||||||
background-color: @black;
|
|
||||||
.select2-results__option--selected:not(.select2-results__option--highlighted) {
|
|
||||||
background-color: @white2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
@import "node_modules/uikit/src/less/uikit.theme.less";
|
|
||||||
|
|
||||||
.label {
|
|
||||||
display: inline-block;
|
|
||||||
padding: @label-padding-vertical @label-padding-horizontal;
|
|
||||||
background: @label-background;
|
|
||||||
line-height: @label-line-height;
|
|
||||||
font-size: @label-font-size;
|
|
||||||
color: @label-color;
|
|
||||||
vertical-align: middle;
|
|
||||||
white-space: nowrap;
|
|
||||||
.hook-label;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-success {
|
|
||||||
background-color: @label-success-background;
|
|
||||||
color: @label-success-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-warning {
|
|
||||||
background-color: @label-warning-background;
|
|
||||||
color: @label-warning-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-danger {
|
|
||||||
background-color: @label-danger-background;
|
|
||||||
color: @label-danger-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-pending {
|
|
||||||
background-color: @global-secondary-background;
|
|
||||||
color: @global-inverse-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
@internal-divider-icon-image: "../img/divider-icon.svg";
|
|
||||||
@internal-form-select-image: "../img/form-select.svg";
|
|
||||||
@internal-form-datalist-image: "../img/form-datalist.svg";
|
|
||||||
@internal-form-radio-image: "../img/form-radio.svg";
|
|
||||||
@internal-form-checkbox-image: "../img/form-checkbox.svg";
|
|
||||||
@internal-form-checkbox-indeterminate-image: "../img/form-checkbox-indeterminate.svg";
|
|
||||||
@internal-nav-parent-close-image: "../img/nav-parent-close.svg";
|
|
||||||
@internal-nav-parent-open-image: "../img/nav-parent-open.svg";
|
|
||||||
@internal-list-bullet-image: "../img/list-bullet.svg";
|
|
||||||
@internal-accordion-open-image: "../img/accordion-open.svg";
|
|
||||||
@internal-accordion-close-image: "../img/accordion-close.svg";
|
|
||||||
|
|
||||||
.hook-card-default() {
|
|
||||||
.uk-light & {
|
|
||||||
background: @card-secondary-background;
|
|
||||||
color: @card-secondary-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hook-card-default-title() {
|
|
||||||
.uk-light & {
|
|
||||||
color: @card-secondary-title-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hook-card-default-hover() {
|
|
||||||
.uk-light & {
|
|
||||||
background-color: @card-secondary-hover-background;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 272 KiB |
@@ -1,55 +1,25 @@
|
|||||||
const component = () => {
|
var scanning = false;
|
||||||
return {
|
function scan() {
|
||||||
progress: 1.0,
|
scanning = true;
|
||||||
generating: false,
|
$('#scan-status > div').removeAttr('hidden');
|
||||||
scanning: false,
|
$('#scan-status > span').attr('hidden', '');
|
||||||
scanTitles: 0,
|
var color = $('#scan').css('color');
|
||||||
scanMs: -1,
|
$('#scan').css('color', 'gray');
|
||||||
themeSetting: '',
|
$.post('/api/admin/scan', function (data) {
|
||||||
|
var ms = data.milliseconds;
|
||||||
init() {
|
var titles = data.titles;
|
||||||
this.getProgress();
|
$('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms');
|
||||||
setInterval(() => {
|
$('#scan-status > span').removeAttr('hidden');
|
||||||
this.getProgress();
|
$('#scan').css('color', color);
|
||||||
}, 5000);
|
$('#scan-status > div').attr('hidden', '');
|
||||||
|
scanning = false;
|
||||||
const setting = loadThemeSetting();
|
|
||||||
this.themeSetting = setting.charAt(0).toUpperCase() + setting.slice(1);
|
|
||||||
},
|
|
||||||
themeChanged(event) {
|
|
||||||
const newSetting = $(event.currentTarget).val().toLowerCase();
|
|
||||||
saveThemeSetting(newSetting);
|
|
||||||
setTheme();
|
|
||||||
},
|
|
||||||
scan() {
|
|
||||||
if (this.scanning) return;
|
|
||||||
this.scanning = true;
|
|
||||||
this.scanMs = -1;
|
|
||||||
this.scanTitles = 0;
|
|
||||||
$.post(`${base_url}api/admin/scan`)
|
|
||||||
.then(data => {
|
|
||||||
this.scanMs = data.milliseconds;
|
|
||||||
this.scanTitles = data.titles;
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
this.scanning = false;
|
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
generateThumbnails() {
|
$(function() {
|
||||||
if (this.generating) return;
|
$('li').click(function() {
|
||||||
this.generating = true;
|
url = $(this).attr('data-url');
|
||||||
this.progress = 0.0;
|
if (url) {
|
||||||
$.post(`${base_url}api/admin/generate_thumbnails`)
|
$(location).attr('href', url);
|
||||||
.then(() => {
|
}
|
||||||
this.getProgress()
|
|
||||||
});
|
});
|
||||||
},
|
|
||||||
getProgress() {
|
|
||||||
$.get(`${base_url}api/admin/thumbnail_progress`)
|
|
||||||
.then(data => {
|
|
||||||
this.progress = data.progress;
|
|
||||||
this.generating = data.progress > 0;
|
|
||||||
});
|
});
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
/**
|
|
||||||
* --- 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');
|
|
||||||
$('.ui-widget-content').addClass('dark');
|
|
||||||
} else {
|
|
||||||
$('html').css('background', '');
|
|
||||||
$('body').removeClass('uk-light');
|
|
||||||
$('.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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,26 +1,18 @@
|
|||||||
/**
|
const truncate = () => {
|
||||||
* Truncate a .uk-card-title element
|
$('.acard .uk-card-title').each((i, e) => {
|
||||||
*
|
|
||||||
* @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,
|
||||||
callback: (truncated) => {
|
callback: (truncated) => {
|
||||||
if (truncated) {
|
if (truncated) {
|
||||||
$(e).attr('uk-tooltip', $(e).attr('data-title'));
|
$(e).attr('uk-tooltip', $(e).attr('data-title'));
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
$(e).removeAttr('uk-tooltip');
|
$(e).removeAttr('uk-tooltip');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
$('.uk-card-title').each((i, e) => {
|
truncate();
|
||||||
// Truncate the title when it first enters the view
|
|
||||||
$(e).one('inview', () => {
|
|
||||||
truncate(e);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,65 +1,30 @@
|
|||||||
const component = () => {
|
$(() => {
|
||||||
return {
|
$('input.uk-checkbox').each((i, e) => {
|
||||||
jobs: [],
|
$(e).change(() => {
|
||||||
paused: undefined,
|
loadConfig();
|
||||||
loading: false,
|
|
||||||
toggling: false,
|
|
||||||
ws: undefined,
|
|
||||||
|
|
||||||
wsConnect(secure = true) {
|
|
||||||
const url = `${secure ? 'wss' : 'ws'}://${location.host}${base_url}api/admin/mangadex/queue`;
|
|
||||||
console.log(`Connecting to ${url}`);
|
|
||||||
this.ws = new WebSocket(url);
|
|
||||||
this.ws.onmessage = event => {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
this.jobs = data.jobs;
|
|
||||||
this.paused = data.paused;
|
|
||||||
};
|
|
||||||
this.ws.onclose = () => {
|
|
||||||
if (this.ws.failed)
|
|
||||||
return this.wsConnect(false);
|
|
||||||
alert('danger', 'Socket connection closed');
|
|
||||||
};
|
|
||||||
this.ws.onerror = () => {
|
|
||||||
if (secure)
|
|
||||||
return this.ws.failed = true;
|
|
||||||
alert('danger', 'Socket connection failed');
|
|
||||||
};
|
|
||||||
},
|
|
||||||
init() {
|
|
||||||
this.wsConnect();
|
|
||||||
this.load();
|
|
||||||
},
|
|
||||||
load() {
|
|
||||||
this.loading = true;
|
|
||||||
$.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;
|
|
||||||
}
|
|
||||||
this.jobs = data.jobs;
|
|
||||||
this.paused = data.paused;
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
this.loading = false;
|
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
jobAction(action, event) {
|
loadConfig();
|
||||||
let url = `${base_url}api/admin/mangadex/queue/${action}`;
|
load();
|
||||||
if (event) {
|
|
||||||
const id = event.currentTarget.closest('tr').id.split('-')[1];
|
const intervalMS = 5000;
|
||||||
url = `${url}?${$.param({
|
setTimeout(() => {
|
||||||
id: id
|
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 = '/api/admin/mangadex/queue/delete';
|
||||||
|
if (id !== undefined)
|
||||||
|
url += '?' + $.param({id: id});
|
||||||
console.log(url);
|
console.log(url);
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
@@ -68,19 +33,41 @@ const component = () => {
|
|||||||
})
|
})
|
||||||
.done(data => {
|
.done(data => {
|
||||||
if (!data.success && data.error) {
|
if (!data.success && data.error) {
|
||||||
alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
|
alert('danger', `Failed to remove job from download queue. Error: ${data.error}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.load();
|
load();
|
||||||
})
|
})
|
||||||
.fail((jqXHR, status) => {
|
.fail((jqXHR, status) => {
|
||||||
alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
alert('danger', `Failed to remove job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
});
|
});
|
||||||
},
|
};
|
||||||
toggle() {
|
const refresh = (id) => {
|
||||||
this.toggling = true;
|
var url = '/api/admin/mangadex/queue/retry';
|
||||||
const action = this.paused ? 'resume' : 'pause';
|
if (id !== undefined)
|
||||||
const url = `${base_url}api/admin/mangadex/queue/${action}`;
|
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}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const toggle = () => {
|
||||||
|
$('#pause-resume-btn').attr('disabled', '');
|
||||||
|
const paused = $('#pause-resume-btn').text() === 'Resume download';
|
||||||
|
const action = paused ? 'resume' : 'pause';
|
||||||
|
const url = `/api/admin/mangadex/queue/${action}`;
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
url: url,
|
url: url,
|
||||||
@@ -90,27 +77,62 @@ const component = () => {
|
|||||||
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
})
|
})
|
||||||
.always(() => {
|
.always(() => {
|
||||||
this.load();
|
load();
|
||||||
this.toggling = false;
|
$('#pause-resume-btn').removeAttr('disabled');
|
||||||
});
|
});
|
||||||
},
|
|
||||||
statusClass(status) {
|
|
||||||
let cls = 'label ';
|
|
||||||
switch (status) {
|
|
||||||
case 'Pending':
|
|
||||||
cls += 'label-pending';
|
|
||||||
break;
|
|
||||||
case 'Completed':
|
|
||||||
cls += 'label-success';
|
|
||||||
break;
|
|
||||||
case 'Error':
|
|
||||||
cls += 'label-danger';
|
|
||||||
break;
|
|
||||||
case 'MissingPages':
|
|
||||||
cls += 'label-warning';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return cls;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
const load = () => {
|
||||||
|
if (loading) return;
|
||||||
|
loading = true;
|
||||||
|
console.log('fetching');
|
||||||
|
$.ajax({
|
||||||
|
type: 'GET',
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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 = 'uk-label ';
|
||||||
|
if (obj.status === 'Completed')
|
||||||
|
cls += 'uk-label-success';
|
||||||
|
if (obj.status === 'Error')
|
||||||
|
cls += 'uk-label-danger';
|
||||||
|
if (obj.status === 'MissingPages')
|
||||||
|
cls += 'uk-label-warning';
|
||||||
|
|
||||||
|
const info = obj.status_message.length > 0 ? '<span uk-icon="info"></span>' : '';
|
||||||
|
const statusSpan = `<span class="${cls}">${obj.status} ${info}</span>`;
|
||||||
|
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><a href="${baseURL}/chapter/${obj.id}">${obj.title}</a></td>
|
||||||
|
<td><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>
|
||||||
|
<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;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
299
public/js/download.js
Normal file
299
public/js/download.js
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
$(() => {
|
||||||
|
$('#search-input').keypress(event => {
|
||||||
|
if (event.which === 13) {
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$('.filter-field').each((i, ele) => {
|
||||||
|
$(ele).change(() => {
|
||||||
|
buildTable();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const selectAll = () => {
|
||||||
|
$('tbody > tr').each((i, e) => {
|
||||||
|
$(e).addClass('ui-selected');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const unselect = () => {
|
||||||
|
$('tbody > tr').each((i, e) => {
|
||||||
|
$(e).removeClass('ui-selected');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const download = () => {
|
||||||
|
const selected = $('tbody > tr.ui-selected');
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||||
|
$('#download-btn').attr('hidden', '');
|
||||||
|
$('#download-spinner').removeAttr('hidden');
|
||||||
|
const ids = selected.map((i, e) => {
|
||||||
|
return $(e).find('td').first().text();
|
||||||
|
}).get();
|
||||||
|
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0);
|
||||||
|
console.log(ids);
|
||||||
|
$.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: '/api/admin/mangadex/download',
|
||||||
|
data: JSON.stringify({chapters: chapters}),
|
||||||
|
contentType: "application/json",
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
console.log(data);
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const successCount = parseInt(data.success);
|
||||||
|
const failCount = parseInt(data.fail);
|
||||||
|
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
||||||
|
window.location.href = '/admin/downloads';
|
||||||
|
});
|
||||||
|
styleModal();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
$('#download-spinner').attr('hidden', '');
|
||||||
|
$('#download-btn').removeAttr('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
styleModal();
|
||||||
|
};
|
||||||
|
const toggleSpinner = () => {
|
||||||
|
var attr = $('#spinner').attr('hidden');
|
||||||
|
if (attr) {
|
||||||
|
$('#spinner').removeAttr('hidden');
|
||||||
|
$('#search-btn').attr('hidden', '');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$('#search-btn').removeAttr('hidden');
|
||||||
|
$('#spinner').attr('hidden', '');
|
||||||
|
}
|
||||||
|
searching = !searching;
|
||||||
|
};
|
||||||
|
var searching = false;
|
||||||
|
var globalChapters;
|
||||||
|
const search = () => {
|
||||||
|
if (searching) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('#manga-details').attr('hidden', '');
|
||||||
|
$('#filter-form').attr('hidden', '');
|
||||||
|
$('table').attr('hidden', '');
|
||||||
|
$('#selection-controls').attr('hidden', '');
|
||||||
|
$('#filter-notification').attr('hidden', '');
|
||||||
|
toggleSpinner();
|
||||||
|
const input = $('input').val();
|
||||||
|
|
||||||
|
if (input === "") {
|
||||||
|
toggleSpinner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var int_id = -1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const path = new URL(input).pathname;
|
||||||
|
const match = /\/title\/([0-9]+)/.exec(path);
|
||||||
|
int_id = parseInt(match[1]);
|
||||||
|
}
|
||||||
|
catch(e) {
|
||||||
|
int_id = parseInt(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (int_id <= 0 || isNaN(int_id)) {
|
||||||
|
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex.');
|
||||||
|
toggleSpinner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$.getJSON("/api/admin/mangadex/manga/" + int_id)
|
||||||
|
.done((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', 'Failed to get manga info. Error: ' + data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cover = baseURL + data.cover_url;
|
||||||
|
$('#cover').attr("src", cover);
|
||||||
|
$('#title').text("Title: " + data.title);
|
||||||
|
$('#artist').text("Artist: " + data.artist);
|
||||||
|
$('#author').text("Author: " + data.author);
|
||||||
|
|
||||||
|
$('#manga-details').removeAttr('hidden');
|
||||||
|
|
||||||
|
console.log(data.chapters);
|
||||||
|
globalChapters = data.chapters;
|
||||||
|
|
||||||
|
let langs = new Set();
|
||||||
|
let group_names = new Set();
|
||||||
|
data.chapters.forEach(chp => {
|
||||||
|
Object.entries(chp.groups).forEach(([k, v]) => {
|
||||||
|
group_names.add(k);
|
||||||
|
});
|
||||||
|
langs.add(chp.language);
|
||||||
|
});
|
||||||
|
|
||||||
|
const comp = (a, b) => {
|
||||||
|
var ai;
|
||||||
|
var bi;
|
||||||
|
try {ai = parseFloat(a);} catch(e) {}
|
||||||
|
try {bi = parseFloat(b);} catch(e) {}
|
||||||
|
if (typeof ai === 'undefined') return -1;
|
||||||
|
if (typeof bi === 'undefined') return 1;
|
||||||
|
if (ai < bi) return 1;
|
||||||
|
if (ai > bi) return -1;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
langs = [...langs].sort();
|
||||||
|
group_names = [...group_names].sort();
|
||||||
|
|
||||||
|
langs.unshift('All');
|
||||||
|
group_names.unshift('All');
|
||||||
|
|
||||||
|
$('select#lang-select').append(langs.map(e => `<option>${e}</option>`).join(''));
|
||||||
|
$('select#group-select').append(group_names.map(e => `<option>${e}</option>`).join(''));
|
||||||
|
|
||||||
|
$('#filter-form').removeAttr('hidden');
|
||||||
|
|
||||||
|
buildTable();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
toggleSpinner();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const parseRange = str => {
|
||||||
|
const regex = /^[\t ]*(?:(?:(<|<=|>|>=)[\t ]*([0-9]+))|(?:([0-9]+))|(?:([0-9]+)[\t ]*-[\t ]*([0-9]+))|(?:[\t ]*))[\t ]*$/m;
|
||||||
|
const matches = str.match(regex);
|
||||||
|
var num;
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
alert('danger', `Failed to parse filter input ${str}`);
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
|
||||||
|
// e.g., <= 30
|
||||||
|
num = parseInt(matches[2]);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
alert('danger', `Failed to parse filter input ${str}`);
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
switch (matches[1]) {
|
||||||
|
case '<':
|
||||||
|
return [null, num - 1];
|
||||||
|
case '<=':
|
||||||
|
return [null, num];
|
||||||
|
case '>':
|
||||||
|
return [num + 1, null];
|
||||||
|
case '>=':
|
||||||
|
return [num, null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (typeof matches[3] !== 'undefined') {
|
||||||
|
// a single number
|
||||||
|
num = parseInt(matches[3]);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
alert('danger', `Failed to parse filter input ${str}`);
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
return [num, num];
|
||||||
|
}
|
||||||
|
else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
|
||||||
|
// e.g., 10 - 23
|
||||||
|
num = parseInt(matches[4]);
|
||||||
|
const n2 = parseInt(matches[5]);
|
||||||
|
if (isNaN(num) || isNaN(n2) || num > n2) {
|
||||||
|
alert('danger', `Failed to parse filter input ${str}`);
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
return [num, n2];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// empty or space only
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const getFilters = () => {
|
||||||
|
const filters = {};
|
||||||
|
$('.uk-select').each((i, ele) => {
|
||||||
|
const id = $(ele).attr('id');
|
||||||
|
const by = id.split('-')[0];
|
||||||
|
const choice = $(ele).val();
|
||||||
|
filters[by] = choice;
|
||||||
|
});
|
||||||
|
filters.volume = parseRange($('#volume-range').val());
|
||||||
|
filters.chapter = parseRange($('#chapter-range').val());
|
||||||
|
return filters;
|
||||||
|
};
|
||||||
|
const buildTable = () => {
|
||||||
|
$('table').attr('hidden', '');
|
||||||
|
$('#selection-controls').attr('hidden', '');
|
||||||
|
$('#filter-notification').attr('hidden', '');
|
||||||
|
console.log('rebuilding table');
|
||||||
|
const filters = getFilters();
|
||||||
|
console.log('filters:', filters);
|
||||||
|
var chapters = globalChapters.slice();
|
||||||
|
Object.entries(filters).forEach(([k, v]) => {
|
||||||
|
if (v === 'All') return;
|
||||||
|
if (k === 'group') {
|
||||||
|
chapters = chapters.filter(c => v in c.groups);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (k === 'lang') {
|
||||||
|
chapters = chapters.filter(c => c.language === v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lb = parseFloat(v[0]);
|
||||||
|
const ub = parseFloat(v[1]);
|
||||||
|
if (isNaN(lb) && isNaN(ub)) return;
|
||||||
|
chapters = chapters.filter(c => {
|
||||||
|
const val = parseFloat(c[k]);
|
||||||
|
if (isNaN(val)) return false;
|
||||||
|
if (isNaN(lb))
|
||||||
|
return val <= ub;
|
||||||
|
else if (isNaN(ub))
|
||||||
|
return val >= lb;
|
||||||
|
else
|
||||||
|
return val >= lb && val <= ub;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
console.log('filtered chapters:', chapters);
|
||||||
|
$('#count-text').text(`${chapters.length} chapters found`);
|
||||||
|
|
||||||
|
const chaptersLimit = 1000;
|
||||||
|
if (chapters.length > chaptersLimit) {
|
||||||
|
$('#filter-notification').text(`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters in this manga. Please use the filter options above to narrow down your search.`);
|
||||||
|
$('#filter-notification').removeAttr('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner = chapters.map(chp => {
|
||||||
|
const group_str = Object.entries(chp.groups).map(([k, v]) => {
|
||||||
|
return `<a href="${baseURL }/group/${v}">${k}</a>`;
|
||||||
|
}).join(' | ');
|
||||||
|
const dark = getTheme() === 'dark' ? 'dark' : '';
|
||||||
|
return `<tr class="ui-widget-content ${dark}">
|
||||||
|
<td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td>
|
||||||
|
<td>${chp.title}</td>
|
||||||
|
<td>${chp.language}</td>
|
||||||
|
<td>${group_str}</td>
|
||||||
|
<td>${chp.volume}</td>
|
||||||
|
<td>${chp.chapter}</td>
|
||||||
|
<td>${moment.unix(chp.time).fromNow()}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
const tbody = `<tbody id="selectable">${inner}</tbody>`;
|
||||||
|
$('tbody').remove();
|
||||||
|
$('table').append(tbody);
|
||||||
|
$('table').removeAttr('hidden');
|
||||||
|
$("#selectable").selectable({
|
||||||
|
filter: 'tr'
|
||||||
|
});
|
||||||
|
$('#selection-controls').removeAttr('hidden');
|
||||||
|
};
|
||||||
5
public/js/fontawesome.min.js
vendored
Normal file
5
public/js/fontawesome.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,60 +0,0 @@
|
|||||||
const component = () => {
|
|
||||||
return {
|
|
||||||
empty: true,
|
|
||||||
titles: [],
|
|
||||||
entries: [],
|
|
||||||
loading: true,
|
|
||||||
|
|
||||||
load() {
|
|
||||||
this.loading = true;
|
|
||||||
this.request('GET', `${base_url}api/admin/titles/missing`, data => {
|
|
||||||
this.titles = data.titles;
|
|
||||||
this.request('GET', `${base_url}api/admin/entries/missing`, data => {
|
|
||||||
this.entries = data.entries;
|
|
||||||
this.loading = false;
|
|
||||||
this.empty = this.entries.length === 0 && this.titles.length === 0;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
rm(event) {
|
|
||||||
const rawID = event.currentTarget.closest('tr').id;
|
|
||||||
const [type, id] = rawID.split('-');
|
|
||||||
const url = `${base_url}api/admin/${type === 'title' ? 'titles' : 'entries'}/missing/${id}`;
|
|
||||||
this.request('DELETE', url, () => {
|
|
||||||
this.load();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
rmAll() {
|
|
||||||
UIkit.modal.confirm('Are you sure? All metadata associated with these items, including their tags and thumbnails, will be deleted from the database.', {
|
|
||||||
labels: {
|
|
||||||
ok: 'Yes, delete them',
|
|
||||||
cancel: 'Cancel'
|
|
||||||
}
|
|
||||||
}).then(() => {
|
|
||||||
this.request('DELETE', `${base_url}api/admin/titles/missing`, () => {
|
|
||||||
this.request('DELETE', `${base_url}api/admin/entries/missing`, () => {
|
|
||||||
this.load();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
request(method, url, cb) {
|
|
||||||
console.log(url);
|
|
||||||
$.ajax({
|
|
||||||
type: method,
|
|
||||||
url: url,
|
|
||||||
contentType: 'application/json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to ${method} ${url}. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cb) cb(data);
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to ${method} ${url}. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
const loadPlugin = id => {
|
|
||||||
localStorage.setItem('plugin', id);
|
|
||||||
const url = `${location.protocol}//${location.host}${location.pathname}`;
|
|
||||||
const newURL = `${url}?${$.param({
|
|
||||||
plugin: id
|
|
||||||
})}`;
|
|
||||||
window.location.href = newURL;
|
|
||||||
};
|
|
||||||
|
|
||||||
$(() => {
|
|
||||||
var storedID = localStorage.getItem('plugin');
|
|
||||||
if (storedID && storedID !== pid) {
|
|
||||||
loadPlugin(storedID);
|
|
||||||
} else {
|
|
||||||
$('#controls').removeAttr('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
$('#search-input').keypress(event => {
|
|
||||||
if (event.which === 13) {
|
|
||||||
search();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$('#plugin-select').val(pid);
|
|
||||||
$('#plugin-select').change(() => {
|
|
||||||
const id = $('#plugin-select').val();
|
|
||||||
loadPlugin(id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let mangaTitle = "";
|
|
||||||
let searching = false;
|
|
||||||
const search = () => {
|
|
||||||
if (searching)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const query = $.param({
|
|
||||||
query: $('#search-input').val(),
|
|
||||||
plugin: pid
|
|
||||||
});
|
|
||||||
$.ajax({
|
|
||||||
type: 'GET',
|
|
||||||
url: `${base_url}api/admin/plugin/list?${query}`,
|
|
||||||
contentType: "application/json",
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
console.log(data);
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Search failed. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mangaTitle = data.title;
|
|
||||||
$('#title-text').text(data.title);
|
|
||||||
buildTable(data.chapters);
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildTable = (chapters) => {
|
|
||||||
$('#table').attr('hidden', '');
|
|
||||||
$('table').empty();
|
|
||||||
|
|
||||||
const keys = Object.keys(chapters[0]).map(k => `<th>${k}</th>`).join('');
|
|
||||||
const thead = `<thead><tr>${keys}</tr></thead>`;
|
|
||||||
$('table').append(thead);
|
|
||||||
|
|
||||||
const rows = chapters.map(ch => {
|
|
||||||
const tds = Object.values(ch).map(v => `<td>${v}</td>`).join('');
|
|
||||||
return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
|
|
||||||
});
|
|
||||||
const tbody = `<tbody id="selectable">${rows}</tbody>`;
|
|
||||||
$('table').append(tbody);
|
|
||||||
|
|
||||||
$('#selectable').selectable({
|
|
||||||
filter: 'tr'
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#table table').tablesorter();
|
|
||||||
$('#table').removeAttr('hidden');
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectAll = () => {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).addClass('ui-selected');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const unselect = () => {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).removeClass('ui-selected');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const download = () => {
|
|
||||||
const selected = $('tbody > tr.ui-selected');
|
|
||||||
if (selected.length === 0) return;
|
|
||||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
|
||||||
$('#download-btn').attr('hidden', '');
|
|
||||||
$('#download-spinner').removeAttr('hidden');
|
|
||||||
const chapters = selected.map((i, e) => {
|
|
||||||
return {
|
|
||||||
id: $(e).attr('data-id'),
|
|
||||||
title: $(e).attr('data-title')
|
|
||||||
}
|
|
||||||
}).get();
|
|
||||||
console.log(chapters);
|
|
||||||
$.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: base_url + 'api/admin/plugin/download',
|
|
||||||
data: JSON.stringify({
|
|
||||||
plugin: pid,
|
|
||||||
chapters: chapters,
|
|
||||||
title: mangaTitle
|
|
||||||
}),
|
|
||||||
contentType: "application/json",
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
console.log(data);
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const successCount = parseInt(data.success);
|
|
||||||
const failCount = parseInt(data.fail);
|
|
||||||
alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`);
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
$('#download-spinner').attr('hidden', '');
|
|
||||||
$('#download-btn').removeAttr('hidden');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,324 +1,81 @@
|
|||||||
const readerComponent = () => {
|
$(function() {
|
||||||
return {
|
function bind() {
|
||||||
loading: true,
|
var controller = new ScrollMagic.Controller();
|
||||||
mode: 'continuous', // Can be 'continuous', 'height' or 'width'
|
|
||||||
msg: 'Loading the web reader. Please wait...',
|
|
||||||
alertClass: 'uk-alert-primary',
|
|
||||||
items: [],
|
|
||||||
curItem: {},
|
|
||||||
enableFlipAnimation: true,
|
|
||||||
flipAnimation: null,
|
|
||||||
longPages: false,
|
|
||||||
lastSavedPage: page,
|
|
||||||
selectedIndex: 0, // 0: not selected; 1: the first page
|
|
||||||
margin: 30,
|
|
||||||
preloadLookahead: 3,
|
|
||||||
|
|
||||||
/**
|
// replace history on scroll
|
||||||
* Initialize the component by fetching the page dimensions
|
$('img').each(function(idx){
|
||||||
*/
|
var scene = new ScrollMagic.Scene({
|
||||||
init(nextTick) {
|
triggerElement: $(this).get(),
|
||||||
$.get(`${base_url}api/dimensions/${tid}/${eid}`)
|
triggerHook: 'onEnter',
|
||||||
.then(data => {
|
reverse: true
|
||||||
if (!data.success && data.error)
|
})
|
||||||
throw new Error(resp.error);
|
.addTo(controller)
|
||||||
const dimensions = data.dimensions;
|
.on('enter', function(event){
|
||||||
|
current = $(event.target.triggerElement()).attr('id');
|
||||||
this.items = dimensions.map((d, i) => {
|
replaceHistory(current);
|
||||||
return {
|
})
|
||||||
id: i + 1,
|
.on('leave', function(event){
|
||||||
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
|
var prev = $(event.target.triggerElement()).prev();
|
||||||
width: d.width,
|
current = $(prev).attr('id');
|
||||||
height: d.height,
|
replaceHistory(current);
|
||||||
};
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const avgRatio = this.items.reduce((acc, cur) => {
|
// poor man's infinite scroll
|
||||||
return acc + cur.height / cur.width
|
var scene = new ScrollMagic.Scene({
|
||||||
}, 0) / this.items.length;
|
triggerElement: $('.next-url').get(),
|
||||||
|
triggerHook: 'onEnter',
|
||||||
console.log(avgRatio);
|
offset: -500
|
||||||
this.longPages = avgRatio > 2;
|
|
||||||
this.loading = false;
|
|
||||||
this.mode = localStorage.getItem('mode') || 'continuous';
|
|
||||||
|
|
||||||
// Here we save a copy of this.mode, and use the copy as
|
|
||||||
// the model-select value. This is because `updateMode`
|
|
||||||
// might change this.mode and make it `height` or `width`,
|
|
||||||
// which are not available in mode-select
|
|
||||||
const mode = this.mode;
|
|
||||||
this.updateMode(this.mode, page, nextTick);
|
|
||||||
$('#mode-select').val(mode);
|
|
||||||
|
|
||||||
const savedMargin = localStorage.getItem('margin');
|
|
||||||
if (savedMargin) {
|
|
||||||
this.margin = savedMargin;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preload Images
|
|
||||||
this.preloadLookahead = +(localStorage.getItem('preloadLookahead') ?? 3);
|
|
||||||
const limit = Math.min(page + this.preloadLookahead, this.items.length + 1);
|
|
||||||
for (let idx = page + 1; idx <= limit; idx++) {
|
|
||||||
this.preloadImage(this.items[idx - 1].url);
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedFlipAnimation = localStorage.getItem('enableFlipAnimation');
|
|
||||||
this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true';
|
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.addTo(controller)
|
||||||
const errMsg = `Failed to get the page dimensions. ${e}`;
|
.on('enter', function(){
|
||||||
console.error(e);
|
var nextURL = $('.next-url').attr('href');
|
||||||
this.alertClass = 'uk-alert-danger';
|
$('.next-url').remove();
|
||||||
this.msg = errMsg;
|
if (!nextURL) {
|
||||||
})
|
console.log('No .next-url found. Reached end of page');
|
||||||
},
|
var lastURL = $('img').last().attr('id');
|
||||||
/**
|
// load the reader URL for the last page to update reading progrss to 100%
|
||||||
* Preload an image, which is expected to be cached
|
$.get(lastURL);
|
||||||
*/
|
$('#next-btn').removeAttr('hidden');
|
||||||
preloadImage(url) {
|
return;
|
||||||
(new Image()).src = url;
|
}
|
||||||
},
|
$('#hidden').load(encodeURI(nextURL) + ' .uk-container', function(res, status, xhr){
|
||||||
/**
|
if (status === 'error') console.log(xhr.statusText);
|
||||||
* Handles the `change` event for the page selector
|
if (status === 'success') {
|
||||||
*/
|
console.log(nextURL + ' loaded');
|
||||||
pageChanged() {
|
// new page loaded to #hidden, we now append it
|
||||||
const p = parseInt($('#page-select').val());
|
$('.uk-section > .uk-container').append($('#hidden .uk-container').children());
|
||||||
this.toPage(p);
|
$('#hidden').empty();
|
||||||
},
|
bind();
|
||||||
/**
|
}
|
||||||
* Handles the `change` event for the mode selector
|
});
|
||||||
*
|
});
|
||||||
* @param {function} nextTick - Alpine $nextTick magic property
|
|
||||||
*/
|
|
||||||
modeChanged(nextTick) {
|
|
||||||
const mode = $('#mode-select').val();
|
|
||||||
const curIdx = parseInt($('#page-select').val());
|
|
||||||
|
|
||||||
this.updateMode(mode, curIdx, nextTick);
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Handles the window `resize` event
|
|
||||||
*/
|
|
||||||
resized() {
|
|
||||||
if (this.mode === 'continuous') return;
|
|
||||||
|
|
||||||
const wideScreen = $(window).width() > $(window).height();
|
|
||||||
this.mode = wideScreen ? 'height' : 'width';
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Handles the window `keydown` event
|
|
||||||
*
|
|
||||||
* @param {Event} event - The triggering event
|
|
||||||
*/
|
|
||||||
keyHandler(event) {
|
|
||||||
if (this.mode === 'continuous') return;
|
|
||||||
|
|
||||||
if (event.key === 'ArrowLeft' || event.key === 'k')
|
|
||||||
this.flipPage(false);
|
|
||||||
if (event.key === 'ArrowRight' || event.key === 'j')
|
|
||||||
this.flipPage(true);
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Flips to the next or the previous page
|
|
||||||
*
|
|
||||||
* @param {bool} isNext - Whether we are going to the next page
|
|
||||||
*/
|
|
||||||
flipPage(isNext) {
|
|
||||||
const idx = parseInt(this.curItem.id);
|
|
||||||
const newIdx = idx + (isNext ? 1 : -1);
|
|
||||||
|
|
||||||
if (newIdx <= 0 || newIdx > this.items.length) return;
|
|
||||||
|
|
||||||
if (newIdx + this.preloadLookahead < this.items.length + 1) {
|
|
||||||
this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.toPage(newIdx);
|
bind();
|
||||||
|
});
|
||||||
if (this.enableFlipAnimation) {
|
$('#page-select').change(function(){
|
||||||
if (isNext)
|
jumpTo(parseInt($('#page-select').val()));
|
||||||
this.flipAnimation = 'right';
|
});
|
||||||
else
|
function showControl(idx) {
|
||||||
this.flipAnimation = 'left';
|
const pageCount = $('#page-select > option').length;
|
||||||
|
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
|
||||||
|
$('#progress-label').text(progressText);
|
||||||
|
$('#page-select').val(idx);
|
||||||
|
UIkit.modal($('#modal-sections')).show();
|
||||||
|
styleModal();
|
||||||
}
|
}
|
||||||
|
function jumpTo(page) {
|
||||||
setTimeout(() => {
|
var ary = window.location.pathname.split('/');
|
||||||
this.flipAnimation = null;
|
ary[ary.length - 1] = page;
|
||||||
}, 500);
|
|
||||||
|
|
||||||
this.replaceHistory(newIdx);
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Jumps to a specific page
|
|
||||||
*
|
|
||||||
* @param {number} idx - One-based index of the page
|
|
||||||
*/
|
|
||||||
toPage(idx) {
|
|
||||||
if (this.mode === 'continuous') {
|
|
||||||
$(`#${idx}`).get(0).scrollIntoView(true);
|
|
||||||
} else {
|
|
||||||
if (idx >= 1 && idx <= this.items.length) {
|
|
||||||
this.curItem = this.items[idx - 1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.replaceHistory(idx);
|
|
||||||
UIkit.modal($('#modal-sections')).hide();
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Replace the address bar history and save the reading progress if necessary
|
|
||||||
*
|
|
||||||
* @param {number} idx - One-based index of the page
|
|
||||||
*/
|
|
||||||
replaceHistory(idx) {
|
|
||||||
const ary = window.location.pathname.split('/');
|
|
||||||
ary[ary.length - 1] = idx;
|
|
||||||
ary.shift(); // remove leading `/`
|
ary.shift(); // remove leading `/`
|
||||||
ary.unshift(window.location.origin);
|
ary.unshift(window.location.origin);
|
||||||
const url = ary.join('/');
|
window.location.replace(ary.join('/'));
|
||||||
this.saveProgress(idx);
|
}
|
||||||
|
function replaceHistory(url) {
|
||||||
history.replaceState(null, "", url);
|
history.replaceState(null, "", url);
|
||||||
},
|
console.log('reading ' + url);
|
||||||
/**
|
|
||||||
* Updates the backend reading progress if:
|
|
||||||
* 1) the current page is more than five pages away from the last
|
|
||||||
* saved page, or
|
|
||||||
* 2) the average height/width ratio of the pages is over 2, or
|
|
||||||
* 3) the current page is the first page, or
|
|
||||||
* 4) the current page is the last page
|
|
||||||
*
|
|
||||||
* @param {number} idx - One-based index of the page
|
|
||||||
* @param {function} cb - Callback
|
|
||||||
*/
|
|
||||||
saveProgress(idx, cb) {
|
|
||||||
idx = parseInt(idx);
|
|
||||||
if (Math.abs(idx - this.lastSavedPage) >= 5 ||
|
|
||||||
this.longPages ||
|
|
||||||
idx === 1 || idx === this.items.length
|
|
||||||
) {
|
|
||||||
this.lastSavedPage = idx;
|
|
||||||
console.log('saving progress', idx);
|
|
||||||
|
|
||||||
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
|
|
||||||
$.ajax({
|
|
||||||
method: 'PUT',
|
|
||||||
url: url,
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
if (data.error)
|
|
||||||
alert('danger', data.error);
|
|
||||||
if (cb) cb();
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
function redirect(url) {
|
||||||
/**
|
|
||||||
* Updates the reader mode
|
|
||||||
*
|
|
||||||
* @param {string} mode - Either `continuous` or `paged`
|
|
||||||
* @param {number} targetPage - The one-based index of the target page
|
|
||||||
* @param {function} nextTick - Alpine $nextTick magic property
|
|
||||||
*/
|
|
||||||
updateMode(mode, targetPage, nextTick) {
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
this.mode = propMode;
|
|
||||||
|
|
||||||
if (mode === 'continuous') {
|
|
||||||
nextTick(() => {
|
|
||||||
this.setupScroller();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
this.toPage(targetPage);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Shows the control modal
|
|
||||||
*
|
|
||||||
* @param {Event} event - The triggering event
|
|
||||||
*/
|
|
||||||
showControl(event) {
|
|
||||||
const idx = event.currentTarget.id;
|
|
||||||
this.selectedIndex = idx;
|
|
||||||
UIkit.modal($('#modal-sections')).show();
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Redirects to a URL
|
|
||||||
*
|
|
||||||
* @param {string} url - The target URL
|
|
||||||
*/
|
|
||||||
redirect(url) {
|
|
||||||
window.location.replace(url);
|
window.location.replace(url);
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Set up the scroll handler that calls `replaceHistory` when an image
|
|
||||||
* enters the view port
|
|
||||||
*/
|
|
||||||
setupScroller() {
|
|
||||||
if (this.mode !== 'continuous') return;
|
|
||||||
$('img').each((idx, el) => {
|
|
||||||
$(el).on('inview', (event, inView) => {
|
|
||||||
if (inView) {
|
|
||||||
const current = $(event.currentTarget).attr('id');
|
|
||||||
|
|
||||||
this.curItem = this.items[current - 1];
|
|
||||||
this.replaceHistory(current);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Marks progress as 100% and jumps to the next entry
|
|
||||||
*
|
|
||||||
* @param {string} nextUrl - URL of the next entry
|
|
||||||
*/
|
|
||||||
nextEntry(nextUrl) {
|
|
||||||
this.saveProgress(this.items.length, () => {
|
|
||||||
this.redirect(nextUrl);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Exits the reader, and sets the reading progress tp 100%
|
|
||||||
*
|
|
||||||
* @param {string} exitUrl - The Exit URL
|
|
||||||
*/
|
|
||||||
exitReader(exitUrl) {
|
|
||||||
this.saveProgress(this.items.length, () => {
|
|
||||||
this.redirect(exitUrl);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the `change` event for the entry selector
|
|
||||||
*/
|
|
||||||
entryChanged() {
|
|
||||||
const id = $('#entry-select').val();
|
|
||||||
this.redirect(`${base_url}reader/${tid}/${id}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
marginChanged() {
|
|
||||||
localStorage.setItem('margin', this.margin);
|
|
||||||
this.toPage(this.selectedIndex);
|
|
||||||
},
|
|
||||||
|
|
||||||
preloadLookaheadChanged() {
|
|
||||||
localStorage.setItem('preloadLookahead', this.preloadLookahead);
|
|
||||||
},
|
|
||||||
|
|
||||||
enableFlipAnimationChanged() {
|
|
||||||
localStorage.setItem('enableFlipAnimation', this.enableFlipAnimation);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
5
public/js/solid.min.js
vendored
Normal file
5
public/js/solid.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,15 +1,123 @@
|
|||||||
$(() => {
|
$(() => {
|
||||||
$('#sort-select').change(() => {
|
const sortItems = () => {
|
||||||
const sort = $('#sort-select').find(':selected').attr('id');
|
const sort = $('#sort-select').find(':selected').attr('id');
|
||||||
const ary = sort.split('-');
|
const ary = sort.split('-');
|
||||||
const by = ary[0];
|
const by = ary[0];
|
||||||
const dir = ary[1];
|
const dir = ary[1];
|
||||||
|
|
||||||
const url = `${location.protocol}//${location.host}${location.pathname}`;
|
let items = $('.item');
|
||||||
const newURL = `${url}?${$.param({
|
items.remove();
|
||||||
sort: by,
|
|
||||||
ascend: dir === 'up' ? 1 : 0
|
const ctxAry = [];
|
||||||
})}`;
|
const keyRange = {};
|
||||||
window.location.href = newURL;
|
if (by === 'auto') {
|
||||||
|
// intelligent sorting
|
||||||
|
items.each((i, item) => {
|
||||||
|
const name = $(item).find('.uk-card-title').text();
|
||||||
|
const regex = /([^0-9\n\r\ ]*)[ ]*([0-9]*\.*[0-9]+)/g;
|
||||||
|
|
||||||
|
const numbers = {};
|
||||||
|
let match = regex.exec(name);
|
||||||
|
while (match) {
|
||||||
|
const key = match[1];
|
||||||
|
const num = parseFloat(match[2]);
|
||||||
|
numbers[key] = num;
|
||||||
|
|
||||||
|
if (!keyRange[key]) {
|
||||||
|
keyRange[key] = [num, num, 1];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
keyRange[key][2] += 1;
|
||||||
|
if (num < keyRange[key][0]) {
|
||||||
|
keyRange[key][0] = num;
|
||||||
|
}
|
||||||
|
else if (num > keyRange[key][1]) {
|
||||||
|
keyRange[key][1] = num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match = regex.exec(name);
|
||||||
|
}
|
||||||
|
ctxAry.push({index: i, numbers: numbers});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(keyRange);
|
||||||
|
|
||||||
|
const sortedKeys = Object.keys(keyRange).filter(k => {
|
||||||
|
return keyRange[k][2] >= items.length / 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
sortedKeys.sort((a, b) => {
|
||||||
|
// sort by frequency of the key first
|
||||||
|
if (keyRange[a][2] !== keyRange[b][2]) {
|
||||||
|
return (keyRange[a][2] < keyRange[b][2]) ? 1 : -1;
|
||||||
|
}
|
||||||
|
// then sort by range of the key
|
||||||
|
return ((keyRange[a][1] - keyRange[a][0]) < (keyRange[b][1] - keyRange[b][0])) ? 1 : -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(sortedKeys);
|
||||||
|
|
||||||
|
ctxAry.sort((a, b) => {
|
||||||
|
for (let i = 0; i < sortedKeys.length; i++) {
|
||||||
|
const key = sortedKeys[i];
|
||||||
|
|
||||||
|
if (a.numbers[key] === undefined && b.numbers[key] === undefined)
|
||||||
|
continue;
|
||||||
|
if (a.numbers[key] === undefined)
|
||||||
|
return 1;
|
||||||
|
if (b.numbers[key] === undefined)
|
||||||
|
return -1;
|
||||||
|
if (a.numbers[key] === b.numbers[key])
|
||||||
|
continue;
|
||||||
|
return (a.numbers[key] > b.numbers[key]) ? 1 : -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedItems = [];
|
||||||
|
ctxAry.forEach(ctx => {
|
||||||
|
sortedItems.push(items[ctx.index]);
|
||||||
|
});
|
||||||
|
items = sortedItems;
|
||||||
|
|
||||||
|
if (dir === 'down') {
|
||||||
|
items.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
items.sort((a, b) => {
|
||||||
|
var res;
|
||||||
|
if (by === 'name')
|
||||||
|
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();
|
||||||
|
else if (by === 'date')
|
||||||
|
res = $(a).attr('data-mtime') > $(b).attr('data-mtime');
|
||||||
|
else if (by === 'progress') {
|
||||||
|
const ap = parseFloat($(a).attr('data-progress'));
|
||||||
|
const bp = parseFloat($(b).attr('data-progress'));
|
||||||
|
if (ap === bp)
|
||||||
|
// if progress is the same, we compare by name
|
||||||
|
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();
|
||||||
|
else
|
||||||
|
res = ap > bp;
|
||||||
|
}
|
||||||
|
if (dir === 'up')
|
||||||
|
return res ? 1 : -1;
|
||||||
|
else
|
||||||
|
return !res ? 1 : -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$('#item-container').append(items);
|
||||||
|
};
|
||||||
|
|
||||||
|
$('#sort-select').change(() => {
|
||||||
|
sortItems();
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($('option#auto-up').length > 0)
|
||||||
|
$('option#auto-up').attr('selected', '');
|
||||||
|
else
|
||||||
|
$('option#name-up').attr('selected', '');
|
||||||
|
|
||||||
|
sortItems();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
const component = () => {
|
|
||||||
return {
|
|
||||||
available: undefined,
|
|
||||||
subscriptions: [],
|
|
||||||
|
|
||||||
init() {
|
|
||||||
$.getJSON(`${base_url}api/admin/mangadex/expires`)
|
|
||||||
.done((data) => {
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.available = Boolean(data.expires && data.expires > Math.floor(Date.now() / 1000));
|
|
||||||
|
|
||||||
if (this.available) this.getSubscriptions();
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
getSubscriptions() {
|
|
||||||
$.getJSON(`${base_url}api/admin/mangadex/subscriptions`)
|
|
||||||
.done(data => {
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', 'Failed to get subscriptions. Error: ' + data.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.subscriptions = data.subscriptions;
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to get subscriptions. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
rm(event) {
|
|
||||||
const id = event.currentTarget.parentNode.getAttribute('data-id');
|
|
||||||
$.ajax({
|
|
||||||
type: 'DELETE',
|
|
||||||
url: `${base_url}api/admin/mangadex/subscriptions/${id}`,
|
|
||||||
contentType: 'application/json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to delete subscription. Error: ${data.error}`);
|
|
||||||
}
|
|
||||||
this.getSubscriptions();
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to delete subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
check(event) {
|
|
||||||
const id = event.currentTarget.parentNode.getAttribute('data-id');
|
|
||||||
$.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`,
|
|
||||||
contentType: 'application/json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to check subscription. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
alert('success', 'Mango is now checking the subscription for updates. This might take a while, but you can safely leave the page.');
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to check subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
formatRange(min, max) {
|
|
||||||
if (!isNaN(min) && isNaN(max)) return `≥ ${min}`;
|
|
||||||
if (isNaN(min) && !isNaN(max)) return `≤ ${max}`;
|
|
||||||
if (isNaN(min) && isNaN(max)) return 'All';
|
|
||||||
|
|
||||||
if (min === max) return `= ${min}`;
|
|
||||||
return `${min} - ${max}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
43
public/js/theme.js
Normal file
43
public/js/theme.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
const getTheme = () => {
|
||||||
|
var theme = localStorage.getItem('theme');
|
||||||
|
if (!theme) theme = 'light';
|
||||||
|
return theme;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTheme = theme => {
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const theme = getTheme();
|
||||||
|
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
||||||
|
setTheme(newTheme);
|
||||||
|
saveTheme(newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTheme = themeStr => {
|
||||||
|
if (themeStr === '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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const styleModal = () => {
|
||||||
|
const color = getTheme() === 'dark' ? '#222' : '';
|
||||||
|
$('.uk-modal-header').css('background', color);
|
||||||
|
$('.uk-modal-body').css('background', color);
|
||||||
|
$('.uk-modal-footer').css('background', color);
|
||||||
|
};
|
||||||
|
|
||||||
|
// do it before document is ready to prevent the initial flash of white
|
||||||
|
setTheme(getTheme());
|
||||||
@@ -1,24 +1,3 @@
|
|||||||
$(() => {
|
|
||||||
setupAcard();
|
|
||||||
});
|
|
||||||
|
|
||||||
const setupAcard = () => {
|
|
||||||
$('.acard.is_entry').click((e) => {
|
|
||||||
if ($(e.target).hasClass('no-modal')) return;
|
|
||||||
const card = $(e.target).closest('.acard');
|
|
||||||
|
|
||||||
showModal(
|
|
||||||
$(card).attr('data-encoded-path'),
|
|
||||||
parseInt($(card).attr('data-pages')),
|
|
||||||
parseFloat($(card).attr('data-progress')),
|
|
||||||
$(card).attr('data-encoded-book-title'),
|
|
||||||
$(card).attr('data-encoded-title'),
|
|
||||||
$(card).attr('data-book-id'),
|
|
||||||
$(card).attr('data-id')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTitle, titleID, entryID) {
|
function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTitle, titleID, entryID) {
|
||||||
const zipPath = decodeURIComponent(encodedPath);
|
const zipPath = decodeURIComponent(encodedPath);
|
||||||
const title = decodeURIComponent(encodedeTitle);
|
const title = decodeURIComponent(encodedeTitle);
|
||||||
@@ -29,22 +8,22 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
|
|||||||
if (percentage === 0) {
|
if (percentage === 0) {
|
||||||
$('#continue-btn').attr('hidden', '');
|
$('#continue-btn').attr('hidden', '');
|
||||||
$('#unread-btn').attr('hidden', '');
|
$('#unread-btn').attr('hidden', '');
|
||||||
} else if (percentage === 100) {
|
}
|
||||||
$('#read-btn').attr('hidden', '');
|
else {
|
||||||
$('#continue-btn').attr('hidden', '');
|
|
||||||
} else {
|
|
||||||
$('#continue-btn').text('Continue from ' + percentage + '%');
|
$('#continue-btn').text('Continue from ' + percentage + '%');
|
||||||
}
|
}
|
||||||
|
if (percentage === 100) {
|
||||||
$('#modal-entry-title').find('span').text(entry);
|
$('#read-btn').attr('hidden', '');
|
||||||
$('#modal-entry-title').next().attr('data-id', titleID);
|
}
|
||||||
$('#modal-entry-title').next().attr('data-entry-id', entryID);
|
$('#modal-title').find('span').text(entry);
|
||||||
$('#modal-entry-title').next().find('.title-rename-field').val(entry);
|
$('#modal-title').next().attr('data-id', titleID);
|
||||||
|
$('#modal-title').next().attr('data-entry-id', entryID);
|
||||||
|
$('#modal-title').next().find('.title-rename-field').val(entry);
|
||||||
$('#path-text').text(zipPath);
|
$('#path-text').text(zipPath);
|
||||||
$('#pages-text').text(pages + ' pages');
|
$('#pages-text').text(pages + ' pages');
|
||||||
|
|
||||||
$('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`);
|
$('#beginning-btn').attr('href', '/reader/' + titleID + '/' + entryID + '/1');
|
||||||
$('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`);
|
$('#continue-btn').attr('href', '/reader/' + titleID + '/' + entryID);
|
||||||
|
|
||||||
$('#read-btn').click(function(){
|
$('#read-btn').click(function(){
|
||||||
updateProgress(titleID, entryID, pages);
|
updateProgress(titleID, entryID, pages);
|
||||||
@@ -53,36 +32,25 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
|
|||||||
updateProgress(titleID, entryID, 0);
|
updateProgress(titleID, entryID, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
|
$('.uk-modal-title.break-word > a').attr('onclick', `edit("${entryID}")`);
|
||||||
|
|
||||||
$('#modal-download-btn').attr('href', `${base_url}api/download/${titleID}/${entryID}`);
|
|
||||||
|
|
||||||
UIkit.modal($('#modal')).show();
|
UIkit.modal($('#modal')).show();
|
||||||
|
styleModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateProgress = (tid, eid, page) => {
|
const updateProgress = (tid, eid, page) => {
|
||||||
let url = `${base_url}api/progress/${tid}/${page}`
|
let 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}`);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,15 +65,13 @@ const renameSubmit = (name, eid) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = $.param({
|
const query = $.param({ entry: eid });
|
||||||
eid: eid
|
let 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: 'PUT',
|
type: 'POST',
|
||||||
url: url,
|
url: url,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
dataType: 'json'
|
dataType: 'json'
|
||||||
@@ -132,7 +98,8 @@ const edit = (eid) => {
|
|||||||
url = item.find('img').attr('data-src');
|
url = item.find('img').attr('data-src');
|
||||||
displayName = item.find('.uk-card-title').attr('data-title');
|
displayName = item.find('.uk-card-title').attr('data-title');
|
||||||
$('#title-progress-control').attr('hidden', '');
|
$('#title-progress-control').attr('hidden', '');
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
$('#title-progress-control').removeAttr('hidden');
|
$('#title-progress-control').removeAttr('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +107,6 @@ 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);
|
||||||
@@ -153,19 +119,18 @@ const edit = (eid) => {
|
|||||||
setupUpload(eid);
|
setupUpload(eid);
|
||||||
|
|
||||||
UIkit.modal($('#edit-modal')).show();
|
UIkit.modal($('#edit-modal')).show();
|
||||||
|
styleModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const setupUpload = (eid) => {
|
const setupUpload = (eid) => {
|
||||||
const upload = $('.upload-field');
|
const upload = $('.upload-field');
|
||||||
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['eid'] = eid;
|
queryObj['entry'] = eid;
|
||||||
const query = $.param(queryObj);
|
const query = $.param(queryObj);
|
||||||
const url = `${base_url}api/admin/upload/cover?${query}`;
|
const url = `/api/admin/upload/cover?${query}`;
|
||||||
console.log(url);
|
console.log(url);
|
||||||
UIkit.upload('.upload-field', {
|
UIkit.upload('.upload-field', {
|
||||||
url: url,
|
url: url,
|
||||||
@@ -192,145 +157,3 @@ const setupUpload = (eid) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const deselectAll = () => {
|
|
||||||
$('.item .uk-card').each((i, e) => {
|
|
||||||
const data = e.__x.$data;
|
|
||||||
data['selected'] = false;
|
|
||||||
});
|
|
||||||
$('#select-bar')[0].__x.$data['count'] = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectAll = () => {
|
|
||||||
let count = 0;
|
|
||||||
$('.item .uk-card').each((i, e) => {
|
|
||||||
const data = e.__x.$data;
|
|
||||||
if (!data['disabled']) {
|
|
||||||
data['selected'] = true;
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$('#select-bar')[0].__x.$data['count'] = count;
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedIDs = () => {
|
|
||||||
const ary = [];
|
|
||||||
$('.item .uk-card').each((i, e) => {
|
|
||||||
const data = e.__x.$data;
|
|
||||||
if (!data['disabled'] && data['selected']) {
|
|
||||||
const item = $(e).closest('.item');
|
|
||||||
ary.push($(item).attr('id'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return ary;
|
|
||||||
};
|
|
||||||
|
|
||||||
const bulkProgress = (action, el) => {
|
|
||||||
const tid = $(el).attr('data-id');
|
|
||||||
const ids = selectedIDs();
|
|
||||||
const url = `${base_url}api/bulk_progress/${action}/${tid}`;
|
|
||||||
$.ajax({
|
|
||||||
type: 'PUT',
|
|
||||||
url: url,
|
|
||||||
contentType: "application/json",
|
|
||||||
dataType: 'json',
|
|
||||||
data: JSON.stringify({
|
|
||||||
ids: ids
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to mark entries as ${action}. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
location.reload();
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to mark entries as ${action}. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
deselectAll();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const tagsComponent = () => {
|
|
||||||
return {
|
|
||||||
isAdmin: false,
|
|
||||||
tags: [],
|
|
||||||
tid: $('.upload-field').attr('data-title-id'),
|
|
||||||
loading: true,
|
|
||||||
|
|
||||||
load(admin) {
|
|
||||||
this.isAdmin = admin;
|
|
||||||
|
|
||||||
$('.tag-select').select2({
|
|
||||||
tags: true,
|
|
||||||
placeholder: this.isAdmin ? 'Tag the title' : 'No tags found',
|
|
||||||
disabled: !this.isAdmin,
|
|
||||||
templateSelection(state) {
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.setAttribute('href', `${base_url}tags/${encodeURIComponent(state.text)}`);
|
|
||||||
a.setAttribute('class', 'uk-link-reset');
|
|
||||||
a.onclick = event => {
|
|
||||||
event.stopPropagation();
|
|
||||||
};
|
|
||||||
a.innerText = state.text;
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.request(`${base_url}api/tags`, 'GET', (data) => {
|
|
||||||
const allTags = data.tags;
|
|
||||||
const url = `${base_url}api/tags/${this.tid}`;
|
|
||||||
this.request(url, 'GET', data => {
|
|
||||||
this.tags = data.tags;
|
|
||||||
allTags.forEach(t => {
|
|
||||||
const op = new Option(t, t, false, this.tags.indexOf(t) >= 0);
|
|
||||||
$('.tag-select').append(op);
|
|
||||||
});
|
|
||||||
$('.tag-select').on('select2:select', e => {
|
|
||||||
this.onAdd(e);
|
|
||||||
});
|
|
||||||
$('.tag-select').on('select2:unselect', e => {
|
|
||||||
this.onDelete(e);
|
|
||||||
});
|
|
||||||
$('.tag-select').on('change', () => {
|
|
||||||
this.onChange();
|
|
||||||
});
|
|
||||||
$('.tag-select').trigger('change');
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onChange() {
|
|
||||||
this.tags = $('.tag-select').select2('data').map(o => o.text);
|
|
||||||
},
|
|
||||||
onAdd(event) {
|
|
||||||
const tag = event.params.data.text;
|
|
||||||
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
|
|
||||||
this.request(url, 'PUT');
|
|
||||||
},
|
|
||||||
onDelete(event) {
|
|
||||||
const tag = event.params.data.text;
|
|
||||||
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
|
|
||||||
this.request(url, 'DELETE');
|
|
||||||
},
|
|
||||||
request(url, method, cb) {
|
|
||||||
$.ajax({
|
|
||||||
url: url,
|
|
||||||
method: method,
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
if (data.success) {
|
|
||||||
if (cb) cb(data);
|
|
||||||
} else {
|
|
||||||
alert('danger', data.error);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
$(() => {
|
$(() => {
|
||||||
var target = base_url + 'admin/user/edit';
|
var target = '/admin/user/edit';
|
||||||
if (username) target += username;
|
if (username) target += username;
|
||||||
$('form').attr('action', target);
|
$('form').attr('action', target);
|
||||||
if (error) alert('danger', error);
|
if (error) alert('danger', error);
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
const remove = (username) => {
|
function remove(username) {
|
||||||
$.ajax({
|
$.post('/api/admin/user/delete/' + username, function(data) {
|
||||||
url: `${base_url}api/admin/user/delete/${username}`,
|
if (data.success) {
|
||||||
type: 'DELETE',
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
if (data.success)
|
|
||||||
location.reload();
|
location.reload();
|
||||||
else
|
}
|
||||||
alert('danger', data.error);
|
else {
|
||||||
})
|
error = data.error;
|
||||||
.fail((jqXHR, status) => {
|
alert('danger', error);
|
||||||
alert('danger', `Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
}
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
User-agent: *
|
|
||||||
Disallow: /
|
|
||||||
78
shard.lock
78
shard.lock
@@ -1,78 +1,34 @@
|
|||||||
version: 2.0
|
version: 1.0
|
||||||
shards:
|
shards:
|
||||||
ameba:
|
ameba:
|
||||||
git: https://github.com/crystal-ameba/ameba.git
|
github: crystal-ameba/ameba
|
||||||
version: 0.14.3
|
version: 0.12.0
|
||||||
|
|
||||||
archive:
|
|
||||||
git: https://github.com/hkalexling/archive.cr.git
|
|
||||||
version: 0.5.0
|
|
||||||
|
|
||||||
baked_file_system:
|
baked_file_system:
|
||||||
git: https://github.com/schovi/baked_file_system.git
|
github: schovi/baked_file_system
|
||||||
version: 0.10.0
|
version: 0.9.8
|
||||||
|
|
||||||
clim:
|
|
||||||
git: https://github.com/at-grandpa/clim.git
|
|
||||||
version: 0.17.1
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
git: https://github.com/crystal-lang/crystal-db.git
|
github: crystal-lang/crystal-db
|
||||||
version: 0.10.1
|
version: 0.9.0
|
||||||
|
|
||||||
duktape:
|
|
||||||
git: https://github.com/jessedoyle/duktape.cr.git
|
|
||||||
version: 1.0.0
|
|
||||||
|
|
||||||
exception_page:
|
exception_page:
|
||||||
git: https://github.com/crystal-loot/exception_page.git
|
github: crystal-loot/exception_page
|
||||||
version: 0.1.5
|
version: 0.1.4
|
||||||
|
|
||||||
http_proxy:
|
|
||||||
git: https://github.com/mamantoha/http_proxy.git
|
|
||||||
version: 0.8.0
|
|
||||||
|
|
||||||
image_size:
|
|
||||||
git: https://github.com/hkalexling/image_size.cr.git
|
|
||||||
version: 0.5.0
|
|
||||||
|
|
||||||
kemal:
|
kemal:
|
||||||
git: https://github.com/kemalcr/kemal.git
|
github: kemalcr/kemal
|
||||||
version: 1.0.0
|
version: 0.26.1
|
||||||
|
|
||||||
kemal-session:
|
|
||||||
git: https://github.com/kemalcr/kemal-session.git
|
|
||||||
version: 1.0.0
|
|
||||||
|
|
||||||
kilt:
|
kilt:
|
||||||
git: https://github.com/jeromegn/kilt.git
|
github: jeromegn/kilt
|
||||||
version: 0.4.1
|
version: 0.4.0
|
||||||
|
|
||||||
koa:
|
|
||||||
git: https://github.com/hkalexling/koa.git
|
|
||||||
version: 0.8.0
|
|
||||||
|
|
||||||
mg:
|
|
||||||
git: https://github.com/hkalexling/mg.git
|
|
||||||
version: 0.5.0+git.commit.697e46e27cde8c3969346e228e372db2455a6264
|
|
||||||
|
|
||||||
myhtml:
|
|
||||||
git: https://github.com/kostya/myhtml.git
|
|
||||||
version: 1.5.8
|
|
||||||
|
|
||||||
open_api:
|
|
||||||
git: https://github.com/hkalexling/open_api.cr.git
|
|
||||||
version: 1.2.1+git.commit.1d3c55dd5534c6b0af18964d031858a08515553a
|
|
||||||
|
|
||||||
radix:
|
radix:
|
||||||
git: https://github.com/luislavena/radix.git
|
github: luislavena/radix
|
||||||
version: 0.4.1
|
version: 0.3.9
|
||||||
|
|
||||||
sqlite3:
|
sqlite3:
|
||||||
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
github: crystal-lang/crystal-sqlite3
|
||||||
version: 0.18.0
|
version: 0.16.0
|
||||||
|
|
||||||
tallboy:
|
|
||||||
git: https://github.com/epoch/tallboy.git
|
|
||||||
version: 0.9.3+git.commit.9be1510bb0391c95e92f1b288f3afb429a73caa6
|
|
||||||
|
|
||||||
|
|||||||
27
shard.yml
27
shard.yml
@@ -1,5 +1,5 @@
|
|||||||
name: mango
|
name: mango
|
||||||
version: 0.24.0
|
version: 0.3.0
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Alex Ling <hkalexling@gmail.com>
|
- Alex Ling <hkalexling@gmail.com>
|
||||||
@@ -8,37 +8,18 @@ targets:
|
|||||||
mango:
|
mango:
|
||||||
main: src/mango.cr
|
main: src/mango.cr
|
||||||
|
|
||||||
crystal: 1.0.0
|
crystal: 0.34.0
|
||||||
|
|
||||||
license: MIT
|
license: MIT
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
kemal:
|
kemal:
|
||||||
github: kemalcr/kemal
|
github: kemalcr/kemal
|
||||||
kemal-session:
|
|
||||||
github: kemalcr/kemal-session
|
|
||||||
sqlite3:
|
sqlite3:
|
||||||
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
|
||||||
archive:
|
|
||||||
github: hkalexling/archive.cr
|
development_dependencies:
|
||||||
ameba:
|
ameba:
|
||||||
github: crystal-ameba/ameba
|
github: crystal-ameba/ameba
|
||||||
clim:
|
|
||||||
github: at-grandpa/clim
|
|
||||||
duktape:
|
|
||||||
github: jessedoyle/duktape.cr
|
|
||||||
myhtml:
|
|
||||||
github: kostya/myhtml
|
|
||||||
http_proxy:
|
|
||||||
github: mamantoha/http_proxy
|
|
||||||
image_size:
|
|
||||||
github: hkalexling/image_size.cr
|
|
||||||
koa:
|
|
||||||
github: hkalexling/koa
|
|
||||||
tallboy:
|
|
||||||
github: epoch/tallboy
|
|
||||||
branch: master
|
|
||||||
mg:
|
|
||||||
github: hkalexling/mg
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ require "./spec_helper"
|
|||||||
|
|
||||||
describe Config do
|
describe Config do
|
||||||
it "creates config if it does not exist" do
|
it "creates config if it does not exist" do
|
||||||
with_default_config do |_, path|
|
with_default_config do |_, _, path|
|
||||||
File.exists?(path).should be_true
|
File.exists?(path).should be_true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
104
spec/mangadex_spec.cr
Normal file
104
spec/mangadex_spec.cr
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
require "./spec_helper"
|
||||||
|
|
||||||
|
include MangaDex
|
||||||
|
|
||||||
|
describe Queue do
|
||||||
|
it "creates DB at given path" do
|
||||||
|
with_queue do |_, path|
|
||||||
|
File.exists?(path).should be_true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "pops nil when empty" do
|
||||||
|
with_queue do |queue|
|
||||||
|
queue.pop.should be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "inserts multiple jobs" do
|
||||||
|
with_queue do |queue|
|
||||||
|
j1 = Job.new "1", "1", "title", "manga_title", JobStatus::Error,
|
||||||
|
Time.utc
|
||||||
|
j2 = Job.new "2", "2", "title", "manga_title", JobStatus::Completed,
|
||||||
|
Time.utc
|
||||||
|
j3 = Job.new "3", "3", "title", "manga_title", JobStatus::Pending,
|
||||||
|
Time.utc
|
||||||
|
j4 = Job.new "4", "4", "title", "manga_title",
|
||||||
|
JobStatus::Downloading, Time.utc
|
||||||
|
count = queue.push [j1, j2, j3, j4]
|
||||||
|
count.should eq 4
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "pops pending job" do
|
||||||
|
with_queue do |queue|
|
||||||
|
job = queue.pop
|
||||||
|
job.should_not be_nil
|
||||||
|
job.not_nil!.id.should eq "3"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "correctly counts jobs" do
|
||||||
|
with_queue do |queue|
|
||||||
|
queue.count.should eq 4
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "deletes job" do
|
||||||
|
with_queue do |queue|
|
||||||
|
queue.delete "4"
|
||||||
|
queue.count.should eq 3
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets status" do
|
||||||
|
with_queue do |queue|
|
||||||
|
job = queue.pop.not_nil!
|
||||||
|
queue.set_status JobStatus::Downloading, job
|
||||||
|
job = queue.pop
|
||||||
|
job.should_not be_nil
|
||||||
|
job.not_nil!.status.should eq JobStatus::Downloading
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sets number of pages" do
|
||||||
|
with_queue do |queue|
|
||||||
|
job = queue.pop.not_nil!
|
||||||
|
queue.set_pages 100, job
|
||||||
|
job = queue.pop
|
||||||
|
job.should_not be_nil
|
||||||
|
job.not_nil!.pages.should eq 100
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "adds fail/success counts" do
|
||||||
|
with_queue do |queue|
|
||||||
|
job = queue.pop.not_nil!
|
||||||
|
queue.add_success job
|
||||||
|
queue.add_success job
|
||||||
|
queue.add_fail job
|
||||||
|
job = queue.pop
|
||||||
|
job.should_not be_nil
|
||||||
|
job.not_nil!.success_count.should eq 2
|
||||||
|
job.not_nil!.fail_count.should eq 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "appends status message" do
|
||||||
|
with_queue do |queue|
|
||||||
|
job = queue.pop.not_nil!
|
||||||
|
queue.add_message "hello", job
|
||||||
|
queue.add_message "world", job
|
||||||
|
job = queue.pop
|
||||||
|
job.should_not be_nil
|
||||||
|
job.not_nil!.status_message.should eq "\nhello\nworld"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "cleans up" do
|
||||||
|
with_queue do
|
||||||
|
true
|
||||||
|
end
|
||||||
|
State.reset
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
require "./spec_helper"
|
|
||||||
require "../src/rename"
|
|
||||||
|
|
||||||
include Rename
|
|
||||||
|
|
||||||
describe Rule do
|
|
||||||
it "raises on nested brackets" do
|
|
||||||
expect_raises Exception do
|
|
||||||
Rule.new "[[]]"
|
|
||||||
end
|
|
||||||
expect_raises Exception do
|
|
||||||
Rule.new "{{}}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "raises on unclosed brackets" do
|
|
||||||
expect_raises Exception do
|
|
||||||
Rule.new "["
|
|
||||||
end
|
|
||||||
expect_raises Exception do
|
|
||||||
Rule.new "{"
|
|
||||||
end
|
|
||||||
expect_raises Exception do
|
|
||||||
Rule.new "[{]}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "raises when closing unopened brackets" do
|
|
||||||
expect_raises Exception do
|
|
||||||
Rule.new "]"
|
|
||||||
end
|
|
||||||
expect_raises Exception do
|
|
||||||
Rule.new "[}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "handles `|` in patterns" do
|
|
||||||
rule = Rule.new "{a|b|c}"
|
|
||||||
rule.render({"b" => "b"}).should eq "b"
|
|
||||||
rule.render({"a" => "a", "b" => "b"}).should eq "a"
|
|
||||||
end
|
|
||||||
|
|
||||||
it "raises on escaped characters" do
|
|
||||||
expect_raises Exception do
|
|
||||||
Rule.new "hello/world"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "handles spaces in patterns" do
|
|
||||||
rule = Rule.new "{ a }"
|
|
||||||
rule.render({"a" => "a"}).should eq "a"
|
|
||||||
end
|
|
||||||
|
|
||||||
it "strips leading and tailing spaces" do
|
|
||||||
rule = Rule.new " hello "
|
|
||||||
rule.render({"a" => "a"}).should eq "hello"
|
|
||||||
end
|
|
||||||
|
|
||||||
it "renders a few examples correctly" do
|
|
||||||
rule = Rule.new "[Ch. {chapter }] {title | id} testing"
|
|
||||||
rule.render({"id" => "ID"}).should eq "ID testing"
|
|
||||||
rule.render({"chapter" => "CH", "id" => "ID"})
|
|
||||||
.should eq "Ch. CH ID testing"
|
|
||||||
rule.render({} of String => String).should eq "testing"
|
|
||||||
end
|
|
||||||
|
|
||||||
it "escapes illegal characters" do
|
|
||||||
rule = Rule.new "{a}"
|
|
||||||
rule.render({"a" => "/?<>:*|\"^"}).should eq "_________"
|
|
||||||
end
|
|
||||||
|
|
||||||
it "strips trailing spaces and dots" do
|
|
||||||
rule = Rule.new "hello. world. .."
|
|
||||||
rule.render({} of String => String).should eq "hello. world"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
require "spec"
|
require "spec"
|
||||||
require "../src/queue"
|
require "../src/context"
|
||||||
require "../src/server"
|
require "../src/server"
|
||||||
require "../src/config"
|
|
||||||
require "../src/main_fiber"
|
|
||||||
|
|
||||||
class State
|
class State
|
||||||
@@hash = {} of String => String
|
@@hash = {} of String => String
|
||||||
@@ -39,18 +37,29 @@ end
|
|||||||
def with_default_config
|
def with_default_config
|
||||||
temp_config = get_tempfile "mango-test-config"
|
temp_config = get_tempfile "mango-test-config"
|
||||||
config = Config.load temp_config.path
|
config = Config.load temp_config.path
|
||||||
config.set_current
|
logger = Logger.new config.log_level
|
||||||
yield config, temp_config.path
|
yield config, logger, temp_config.path
|
||||||
temp_config.delete
|
temp_config.delete
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_storage
|
def with_storage
|
||||||
with_default_config do
|
with_default_config do |_, logger|
|
||||||
temp_db = get_tempfile "mango-test-db"
|
temp_db = get_tempfile "mango-test-db"
|
||||||
storage = Storage.new temp_db.path, false
|
storage = Storage.new temp_db.path, logger
|
||||||
clear = yield storage, temp_db.path
|
clear = yield storage, temp_db.path
|
||||||
if clear == true
|
if clear == true
|
||||||
temp_db.delete
|
temp_db.delete
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def with_queue
|
||||||
|
with_default_config do |_, logger|
|
||||||
|
temp_queue_db = get_tempfile "mango-test-queue-db"
|
||||||
|
queue = MangaDex::Queue.new temp_queue_db.path, logger
|
||||||
|
clear = yield queue, temp_queue_db.path
|
||||||
|
if clear == true
|
||||||
|
temp_queue_db.delete
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ describe Storage do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it "deletes user" do
|
it "deletes user" do
|
||||||
with_storage &.delete_user "admin"
|
with_storage do |storage|
|
||||||
|
storage.delete_user "admin"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "creates new user" do
|
it "creates new user" do
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
require "./spec_helper"
|
require "./spec_helper"
|
||||||
|
|
||||||
describe "compare_numerically" do
|
describe "compare_alphanumerically" do
|
||||||
it "sorts filenames with leading zeros correctly" do
|
it "sorts filenames with leading zeros correctly" do
|
||||||
ary = ["010.jpg", "001.jpg", "002.png"]
|
ary = ["010.jpg", "001.jpg", "002.png"]
|
||||||
ary.sort! { |a, b|
|
ary.sort! { |a, b|
|
||||||
compare_numerically a, b
|
compare_alphanumerically a, b
|
||||||
}
|
}
|
||||||
ary.should eq ["001.jpg", "002.png", "010.jpg"]
|
ary.should eq ["001.jpg", "002.png", "010.jpg"]
|
||||||
end
|
end
|
||||||
@@ -12,7 +12,7 @@ describe "compare_numerically" do
|
|||||||
it "sorts filenames without leading zeros correctly" do
|
it "sorts filenames without leading zeros correctly" do
|
||||||
ary = ["10.jpg", "1.jpg", "0.png", "0100.jpg"]
|
ary = ["10.jpg", "1.jpg", "0.png", "0100.jpg"]
|
||||||
ary.sort! { |a, b|
|
ary.sort! { |a, b|
|
||||||
compare_numerically a, b
|
compare_alphanumerically a, b
|
||||||
}
|
}
|
||||||
ary.should eq ["0.png", "1.jpg", "10.jpg", "0100.jpg"]
|
ary.should eq ["0.png", "1.jpg", "10.jpg", "0100.jpg"]
|
||||||
end
|
end
|
||||||
@@ -21,53 +21,16 @@ describe "compare_numerically" do
|
|||||||
it "sorts like the stack exchange post" do
|
it "sorts like the stack exchange post" do
|
||||||
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
|
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
|
||||||
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
|
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
|
||||||
ary.reverse.sort! { |a, b|
|
ary.reverse.sort { |a, b|
|
||||||
compare_numerically a, b
|
compare_alphanumerically a, b
|
||||||
}.should eq ary
|
}.should eq ary
|
||||||
end
|
end
|
||||||
|
|
||||||
# https://github.com/hkalexling/Mango/issues/22
|
# https://github.com/hkalexling/Mango/issues/22
|
||||||
it "handles numbers larger than Int32" do
|
it "handles numbers larger than Int32" do
|
||||||
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
|
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
|
||||||
ary.reverse.sort! { |a, b|
|
ary.reverse.sort { |a, b|
|
||||||
compare_numerically a, b
|
compare_alphanumerically a, b
|
||||||
}.should eq ary
|
}.should eq ary
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "is_supported_file" do
|
|
||||||
it "returns true when the filename has a supported extension" do
|
|
||||||
filename = "manga.cbz"
|
|
||||||
is_supported_file(filename).should eq true
|
|
||||||
end
|
|
||||||
|
|
||||||
it "returns true when the filename does not have a supported extension" do
|
|
||||||
filename = "info.json"
|
|
||||||
is_supported_file(filename).should eq false
|
|
||||||
end
|
|
||||||
|
|
||||||
it "is case insensitive" do
|
|
||||||
filename = "manga.ZiP"
|
|
||||||
is_supported_file(filename).should eq true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "chapter_sort" do
|
|
||||||
it "sorts correctly" do
|
|
||||||
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
|
|
||||||
sorter = ChapterSorter.new ary
|
|
||||||
ary.reverse.sort! do |a, b|
|
|
||||||
sorter.compare a, b
|
|
||||||
end.should eq ary
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "sanitize_filename" do
|
|
||||||
it "returns a random string for empty sanitized string" do
|
|
||||||
sanitize_filename("..").should_not eq sanitize_filename("..")
|
|
||||||
end
|
|
||||||
it "sanitizes correctly" do
|
|
||||||
sanitize_filename(".. \n\v.\rマンゴー/|*()<[1/2] 3.14 hello world ")
|
|
||||||
.should eq "マンゴー_()[1_2] 3.14 hello world"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
require "compress/zip"
|
|
||||||
require "archive"
|
|
||||||
|
|
||||||
# A unified class to handle all supported archive formats. It uses the
|
|
||||||
# Compress::Zip module in crystal standard library if the target file is
|
|
||||||
# a zip archive. Otherwise it uses `archive.cr`.
|
|
||||||
class ArchiveFile
|
|
||||||
def initialize(@filename : String)
|
|
||||||
if [".cbz", ".zip"].includes? File.extname filename
|
|
||||||
@archive_file = Compress::Zip::File.new filename
|
|
||||||
else
|
|
||||||
@archive_file = Archive::File.new filename
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.open(filename : String, &)
|
|
||||||
s = self.new filename
|
|
||||||
yield s
|
|
||||||
s.close
|
|
||||||
end
|
|
||||||
|
|
||||||
def close
|
|
||||||
if @archive_file.is_a? Compress::Zip::File
|
|
||||||
@archive_file.as(Compress::Zip::File).close
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Lists all file entries
|
|
||||||
def entries
|
|
||||||
ary = [] of Compress::Zip::File::Entry | Archive::Entry
|
|
||||||
@archive_file.entries.map do |e|
|
|
||||||
if (e.is_a? Compress::Zip::File::Entry && e.file?) ||
|
|
||||||
(e.is_a? Archive::Entry && e.info.file?)
|
|
||||||
ary.push e
|
|
||||||
end
|
|
||||||
end
|
|
||||||
ary
|
|
||||||
end
|
|
||||||
|
|
||||||
def read_entry(e : Compress::Zip::File::Entry | Archive::Entry) : Bytes?
|
|
||||||
if e.is_a? Compress::Zip::File::Entry
|
|
||||||
data = nil
|
|
||||||
e.open do |io|
|
|
||||||
slice = Bytes.new e.uncompressed_size
|
|
||||||
bytes_read = io.read_fully? slice
|
|
||||||
data = slice if bytes_read
|
|
||||||
end
|
|
||||||
data
|
|
||||||
else
|
|
||||||
e.read
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def check
|
|
||||||
if @archive_file.is_a? Archive::File
|
|
||||||
@archive_file.as(Archive::File).check
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
41
src/assets/lang_codes.csv
Normal file
41
src/assets/lang_codes.csv
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
Arabic,sa
|
||||||
|
Bengali,bd
|
||||||
|
Bulgarian,bg
|
||||||
|
Burmese,mm
|
||||||
|
Catalan,ct
|
||||||
|
Chinese (Simp),cn
|
||||||
|
Chinese (Trad),hk
|
||||||
|
Czech,cz
|
||||||
|
Danish,dk
|
||||||
|
Dutch,nl
|
||||||
|
English,gb
|
||||||
|
Filipino,ph
|
||||||
|
Finnish,fi
|
||||||
|
French,fr
|
||||||
|
German,de
|
||||||
|
Greek,gr
|
||||||
|
Hebrew,il
|
||||||
|
Hindi,in
|
||||||
|
Hungarian,hu
|
||||||
|
Indonesian,id
|
||||||
|
Italian,it
|
||||||
|
Japanese,jp
|
||||||
|
Korean,kr
|
||||||
|
Lithuanian,lt
|
||||||
|
Malay,my
|
||||||
|
Mongolian,mn
|
||||||
|
Other,
|
||||||
|
Persian,ir
|
||||||
|
Polish,pl
|
||||||
|
Portuguese (Br),br
|
||||||
|
Portuguese (Pt),pt
|
||||||
|
Romanian,ro
|
||||||
|
Russian,ru
|
||||||
|
Serbo-Croatian,rs
|
||||||
|
Spanish (Es),es
|
||||||
|
Spanish (LATAM),mx
|
||||||
|
Swedish,se
|
||||||
|
Thai,th
|
||||||
|
Turkish,tr
|
||||||
|
Ukrainian,ua
|
||||||
|
Vietnames,vn
|
||||||
|
@@ -3,69 +3,43 @@ require "yaml"
|
|||||||
class Config
|
class Config
|
||||||
include YAML::Serializable
|
include YAML::Serializable
|
||||||
|
|
||||||
@[YAML::Field(ignore: true)]
|
|
||||||
property path : String = ""
|
|
||||||
property host : String = "0.0.0.0"
|
|
||||||
property port : Int32 = 9000
|
property port : Int32 = 9000
|
||||||
property base_url : String = "/"
|
|
||||||
property session_secret : String = "mango-session-secret"
|
|
||||||
property library_path : String = File.expand_path "~/mango/library",
|
property library_path : String = File.expand_path "~/mango/library",
|
||||||
home: true
|
home: true
|
||||||
property library_cache_path = File.expand_path "~/mango/library.yml.gz",
|
|
||||||
home: true
|
|
||||||
property db_path : String = File.expand_path "~/mango/mango.db", home: true
|
property db_path : String = File.expand_path "~/mango/mango.db", home: true
|
||||||
property scan_interval_minutes : Int32 = 5
|
@[YAML::Field(key: "scan_interval_minutes")]
|
||||||
property thumbnail_generation_interval_hours : Int32 = 24
|
property scan_interval : Int32 = 5
|
||||||
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 disable_ellipsis_truncation : Bool = false
|
||||||
home: true
|
|
||||||
property download_timeout_seconds : Int32 = 30
|
|
||||||
property cache_enabled = false
|
|
||||||
property cache_size_mbs = 50
|
|
||||||
property cache_log_enabled = true
|
|
||||||
property disable_login = false
|
|
||||||
property default_username = ""
|
|
||||||
property auth_proxy_header_name = ""
|
|
||||||
property mangadex = Hash(String, String | Int32).new
|
property mangadex = Hash(String, String | Int32).new
|
||||||
|
|
||||||
@[YAML::Field(ignore: true)]
|
@[YAML::Field(ignore: true)]
|
||||||
@mangadex_defaults = {
|
@mangadex_defaults = {
|
||||||
"base_url" => "https://mangadex.org",
|
"base_url" => "https://mangadex.org",
|
||||||
"api_url" => "https://api.mangadex.org/v2",
|
"api_url" => "https://mangadex.org/api",
|
||||||
"download_wait_seconds" => 5,
|
"download_wait_seconds" => 5,
|
||||||
"download_retries" => 4,
|
"download_retries" => 4,
|
||||||
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
|
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
|
||||||
home: true),
|
home: true),
|
||||||
"chapter_rename_rule" => "[Vol.{volume} ][Ch.{chapter} ]{title|id}",
|
|
||||||
"manga_rename_rule" => "{title}",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@singlet : Config?
|
|
||||||
|
|
||||||
def self.current
|
|
||||||
@@singlet.not_nil!
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_current
|
|
||||||
@@singlet = self
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.load(path : String?)
|
def self.load(path : String?)
|
||||||
path = "~/.config/mango/config.yml" if path.nil?
|
path = "~/.config/mango/config.yml" if path.nil?
|
||||||
cfg_path = File.expand_path path, home: true
|
cfg_path = File.expand_path path, home: true
|
||||||
if File.exists? cfg_path
|
if File.exists? cfg_path
|
||||||
config = self.from_yaml File.read cfg_path
|
config = self.from_yaml File.read cfg_path
|
||||||
config.path = path
|
|
||||||
config.fill_defaults
|
config.fill_defaults
|
||||||
config.preprocess
|
|
||||||
return config
|
return config
|
||||||
end
|
end
|
||||||
puts "The config file #{cfg_path} does not exist." \
|
puts "The config file #{cfg_path} does not exist." \
|
||||||
"Dumping the default config there."
|
" Do you want mango to dump the default config there? [Y/n]"
|
||||||
|
input = gets
|
||||||
|
if input && input.downcase == "n"
|
||||||
|
abort "Aborting..."
|
||||||
|
end
|
||||||
default = self.allocate
|
default = self.allocate
|
||||||
default.path = path
|
|
||||||
default.fill_defaults
|
default.fill_defaults
|
||||||
cfg_dir = File.dirname cfg_path
|
cfg_dir = File.dirname cfg_path
|
||||||
unless Dir.exists? cfg_dir
|
unless Dir.exists? cfg_dir
|
||||||
@@ -85,36 +59,4 @@ class Config
|
|||||||
end
|
end
|
||||||
{% end %}
|
{% end %}
|
||||||
end
|
end
|
||||||
|
|
||||||
def preprocess
|
|
||||||
unless base_url.starts_with? "/"
|
|
||||||
raise "base url (#{base_url}) should start with `/`"
|
|
||||||
end
|
|
||||||
unless base_url.ends_with? "/"
|
|
||||||
@base_url += "/"
|
|
||||||
end
|
|
||||||
if disable_login && default_username.empty?
|
|
||||||
raise "Login is disabled, but default username is not set. " \
|
|
||||||
"Please set a default username"
|
|
||||||
end
|
|
||||||
|
|
||||||
# `Logger.default` is not available yet
|
|
||||||
Log.setup :debug
|
|
||||||
unless mangadex["api_url"] =~ /\/v2/
|
|
||||||
Log.warn { "It looks like you are using the deprecated MangaDex API " \
|
|
||||||
"v1 in your config file. Please update it to " \
|
|
||||||
"https://api.mangadex.org/v2 to suppress this warning." }
|
|
||||||
mangadex["api_url"] = "https://api.mangadex.org/v2"
|
|
||||||
end
|
|
||||||
if mangadex["api_url"] =~ /\/api\/v2/
|
|
||||||
Log.warn { "It looks like you are using the outdated MangaDex API " \
|
|
||||||
"url (mangadex.org/api/v2) in your config file. Please " \
|
|
||||||
"update it to https://api.mangadex.org/v2 to suppress this " \
|
|
||||||
"warning." }
|
|
||||||
mangadex["api_url"] = "https://api.mangadex.org/v2"
|
|
||||||
end
|
|
||||||
|
|
||||||
mangadex["api_url"] = mangadex["api_url"].to_s.rstrip "/"
|
|
||||||
mangadex["base_url"] = mangadex["base_url"].to_s.rstrip "/"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
21
src/context.cr
Normal file
21
src/context.cr
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
require "./config"
|
||||||
|
require "./library"
|
||||||
|
require "./storage"
|
||||||
|
require "./logger"
|
||||||
|
|
||||||
|
class Context
|
||||||
|
property config : Config
|
||||||
|
property library : Library
|
||||||
|
property storage : Storage
|
||||||
|
property logger : Logger
|
||||||
|
property queue : MangaDex::Queue
|
||||||
|
|
||||||
|
def initialize(@config, @logger, @library, @storage, @queue)
|
||||||
|
end
|
||||||
|
|
||||||
|
{% for lvl in Logger::LEVELS %}
|
||||||
|
def {{lvl.id}}(msg)
|
||||||
|
@logger.{{lvl.id}} msg
|
||||||
|
end
|
||||||
|
{% end %}
|
||||||
|
end
|
||||||
@@ -1,103 +1,25 @@
|
|||||||
require "kemal"
|
require "kemal"
|
||||||
require "../storage"
|
require "../storage"
|
||||||
require "../util/*"
|
require "../util"
|
||||||
|
|
||||||
class AuthHandler < Kemal::Handler
|
class AuthHandler < Kemal::Handler
|
||||||
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
|
def initialize(@storage : Storage)
|
||||||
|
|
||||||
BASIC = "Basic"
|
|
||||||
AUTH = "Authorization"
|
|
||||||
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
|
|
||||||
"You have to login with proper credentials"
|
|
||||||
HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\""
|
|
||||||
|
|
||||||
def require_basic_auth(env)
|
|
||||||
env.response.status_code = 401
|
|
||||||
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
|
|
||||||
env.response.print AUTH_MESSAGE
|
|
||||||
end
|
|
||||||
|
|
||||||
def require_auth(env)
|
|
||||||
env.session.string "callback", env.request.path
|
|
||||||
redirect env, "/login"
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_token(env)
|
|
||||||
token = env.session.string? "token"
|
|
||||||
!token.nil? && Storage.default.verify_token token
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_token_admin(env)
|
|
||||||
token = env.session.string? "token"
|
|
||||||
!token.nil? && Storage.default.verify_admin token
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_auth_header(env)
|
|
||||||
if env.request.headers[AUTH]?
|
|
||||||
if value = env.request.headers[AUTH]
|
|
||||||
if value.size > 0 && value.starts_with?(BASIC)
|
|
||||||
token = verify_user value
|
|
||||||
return false if token.nil?
|
|
||||||
|
|
||||||
env.session.string "token", token
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
def verify_user(value)
|
|
||||||
username, password = Base64.decode_string(value[BASIC.size + 1..-1])
|
|
||||||
.split(":")
|
|
||||||
Storage.default.verify_user username, password
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def call(env)
|
def call(env)
|
||||||
# Skip all authentication if requesting /login, /logout, /api/login,
|
return call_next(env) if request_path_startswith env, ["/login", "/logout"]
|
||||||
# or a static file
|
|
||||||
if request_path_startswith(env, ["/login", "/logout", "/api/login"]) ||
|
cookie = env.request.cookies.find { |c| c.name == "token" }
|
||||||
requesting_static_file env
|
if cookie.nil? || !@storage.verify_token cookie.value
|
||||||
return call_next(env)
|
return env.redirect "/login"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check user is logged in
|
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
|
||||||
if validate_token env
|
unless @storage.verify_admin cookie.value
|
||||||
# Skip if the request has a valid token
|
|
||||||
elsif Config.current.disable_login
|
|
||||||
# Check default username if login is disabled
|
|
||||||
unless Storage.default.username_exists Config.current.default_username
|
|
||||||
Logger.warn "Default username #{Config.current.default_username} " \
|
|
||||||
"does not exist"
|
|
||||||
return require_auth env
|
|
||||||
end
|
|
||||||
elsif !Config.current.auth_proxy_header_name.empty?
|
|
||||||
# Check auth proxy if present
|
|
||||||
username = env.request.headers[Config.current.auth_proxy_header_name]?
|
|
||||||
unless username && Storage.default.username_exists username
|
|
||||||
Logger.warn "Header #{Config.current.auth_proxy_header_name} unset " \
|
|
||||||
"or is not a valid username"
|
|
||||||
return require_auth env
|
|
||||||
end
|
|
||||||
elsif request_path_startswith env, ["/opds"]
|
|
||||||
# Check auth header if requesting an opds page
|
|
||||||
unless validate_auth_header env
|
|
||||||
return require_basic_auth env
|
|
||||||
end
|
|
||||||
else
|
|
||||||
return require_auth env
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check admin access when requesting an admin page
|
|
||||||
if request_path_startswith env, %w(/admin /api/admin /download)
|
|
||||||
unless is_admin? env
|
|
||||||
env.response.status_code = 403
|
env.response.status_code = 403
|
||||||
return send_error_page "HTTP 403: You are not authorized to visit " \
|
|
||||||
"#{env.request.path}"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Let the request go through if it passes the above checks
|
|
||||||
call_next env
|
call_next env
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,17 +2,20 @@ require "kemal"
|
|||||||
require "../logger"
|
require "../logger"
|
||||||
|
|
||||||
class LogHandler < Kemal::BaseLogHandler
|
class LogHandler < Kemal::BaseLogHandler
|
||||||
|
def initialize(@logger : Logger)
|
||||||
|
end
|
||||||
|
|
||||||
def call(env)
|
def call(env)
|
||||||
elapsed_time = Time.measure { call_next env }
|
elapsed_time = Time.measure { call_next env }
|
||||||
elapsed_text = elapsed_text elapsed_time
|
elapsed_text = elapsed_text elapsed_time
|
||||||
msg = "#{env.response.status_code} #{env.request.method}" \
|
msg = "#{env.response.status_code} #{env.request.method}" \
|
||||||
" #{env.request.resource} #{elapsed_text}"
|
" #{env.request.resource} #{elapsed_text}"
|
||||||
Logger.debug msg
|
@logger.debug msg
|
||||||
env
|
env
|
||||||
end
|
end
|
||||||
|
|
||||||
def write(msg)
|
def write(msg)
|
||||||
Logger.debug msg
|
@logger.debug msg
|
||||||
end
|
end
|
||||||
|
|
||||||
private def elapsed_text(elapsed)
|
private def elapsed_text(elapsed)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
require "baked_file_system"
|
require "baked_file_system"
|
||||||
require "kemal"
|
require "kemal"
|
||||||
require "../util/*"
|
require "../util"
|
||||||
|
|
||||||
class FS
|
class FS
|
||||||
extend BakedFileSystem
|
extend BakedFileSystem
|
||||||
@@ -16,14 +16,16 @@ class FS
|
|||||||
end
|
end
|
||||||
|
|
||||||
class StaticHandler < Kemal::Handler
|
class StaticHandler < Kemal::Handler
|
||||||
|
@dirs = ["/css", "/js", "/img", "/favicon.ico"]
|
||||||
|
|
||||||
def call(env)
|
def call(env)
|
||||||
if requesting_static_file env
|
if request_path_startswith env, @dirs
|
||||||
file = FS.get? env.request.path
|
file = FS.get? env.request.path
|
||||||
return call_next env if file.nil?
|
return call_next env if file.nil?
|
||||||
|
|
||||||
slice = Bytes.new file.size
|
slice = Bytes.new file.size
|
||||||
file.read slice
|
file.read slice
|
||||||
return send_file env, slice, MIME.from_filename file.path
|
return send_file env, slice, file.mime_type
|
||||||
end
|
end
|
||||||
call_next env
|
call_next env
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
require "kemal"
|
require "kemal"
|
||||||
require "../util/*"
|
require "../util"
|
||||||
|
|
||||||
class UploadHandler < Kemal::Handler
|
class UploadHandler < Kemal::Handler
|
||||||
def initialize(@upload_dir : String)
|
def initialize(@upload_dir : String)
|
||||||
@@ -11,9 +11,7 @@ class UploadHandler < Kemal::Handler
|
|||||||
return call_next env
|
return call_next env
|
||||||
end
|
end
|
||||||
|
|
||||||
ary = env.request.path.split(File::SEPARATOR).select do |part|
|
ary = env.request.path.split(File::SEPARATOR).select { |part| !part.empty? }
|
||||||
!part.empty?
|
|
||||||
end
|
|
||||||
ary[0] = @upload_dir
|
ary[0] = @upload_dir
|
||||||
path = File.join ary
|
path = File.join ary
|
||||||
|
|
||||||
|
|||||||
431
src/library.cr
Normal file
431
src/library.cr
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
require "zip"
|
||||||
|
require "mime"
|
||||||
|
require "json"
|
||||||
|
require "uri"
|
||||||
|
require "./util"
|
||||||
|
|
||||||
|
struct Image
|
||||||
|
property data : Bytes
|
||||||
|
property mime : String
|
||||||
|
property filename : String
|
||||||
|
property size : Int32
|
||||||
|
|
||||||
|
def initialize(@data, @mime, @filename, @size)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Entry
|
||||||
|
property zip_path : String, book : Title, title : String,
|
||||||
|
size : String, pages : Int32, id : String, title_id : String,
|
||||||
|
encoded_path : String, encoded_title : String, mtime : Time
|
||||||
|
|
||||||
|
def initialize(path, @book, @title_id, storage)
|
||||||
|
@zip_path = path
|
||||||
|
@encoded_path = URI.encode path
|
||||||
|
@title = File.basename path, File.extname path
|
||||||
|
@encoded_title = URI.encode @title
|
||||||
|
@size = (File.size path).humanize_bytes
|
||||||
|
file = Zip::File.new path
|
||||||
|
@pages = file.entries.count do |e|
|
||||||
|
["image/jpeg", "image/png"].includes? \
|
||||||
|
MIME.from_filename? e.filename
|
||||||
|
end
|
||||||
|
file.close
|
||||||
|
@id = storage.get_id @zip_path, false
|
||||||
|
@mtime = File.info(@zip_path).modification_time
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(json : JSON::Builder)
|
||||||
|
json.object do
|
||||||
|
{% for str in ["zip_path", "title", "size", "id", "title_id",
|
||||||
|
"encoded_path", "encoded_title"] %}
|
||||||
|
json.field {{str}}, @{{str.id}}
|
||||||
|
{% end %}
|
||||||
|
json.field "display_name", @book.display_name @title
|
||||||
|
json.field "cover_url", cover_url
|
||||||
|
json.field "pages" { json.number @pages }
|
||||||
|
json.field "mtime" { json.number @mtime.to_unix }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def display_name
|
||||||
|
@book.display_name @title
|
||||||
|
end
|
||||||
|
|
||||||
|
def encoded_display_name
|
||||||
|
URI.encode display_name
|
||||||
|
end
|
||||||
|
|
||||||
|
def cover_url
|
||||||
|
url = "/api/page/#{@title_id}/#{@id}/1"
|
||||||
|
TitleInfo.new @book.dir do |info|
|
||||||
|
info_url = info.entry_cover_url[@title]?
|
||||||
|
unless info_url.nil? || info_url.empty?
|
||||||
|
url = info_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
url
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_page(page_num)
|
||||||
|
Zip::File.open @zip_path do |file|
|
||||||
|
page = file.entries
|
||||||
|
.select { |e|
|
||||||
|
["image/jpeg", "image/png"].includes? \
|
||||||
|
MIME.from_filename? e.filename
|
||||||
|
}
|
||||||
|
.sort { |a, b|
|
||||||
|
compare_alphanumerically a.filename, b.filename
|
||||||
|
}
|
||||||
|
.[page_num - 1]
|
||||||
|
page.open do |io|
|
||||||
|
slice = Bytes.new page.uncompressed_size
|
||||||
|
bytes_read = io.read_fully? slice
|
||||||
|
unless bytes_read
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return Image.new slice, MIME.from_filename(page.filename),
|
||||||
|
page.filename, bytes_read
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Title
|
||||||
|
property dir : String, parent_id : String, title_ids : Array(String),
|
||||||
|
entries : Array(Entry), title : String, id : String,
|
||||||
|
encoded_title : String, mtime : Time
|
||||||
|
|
||||||
|
def initialize(@dir : String, @parent_id, storage,
|
||||||
|
@logger : Logger, @library : Library)
|
||||||
|
@id = storage.get_id @dir, true
|
||||||
|
@title = File.basename dir
|
||||||
|
@encoded_title = URI.encode @title
|
||||||
|
@title_ids = [] of String
|
||||||
|
@entries = [] of Entry
|
||||||
|
@mtime = File.info(dir).modification_time
|
||||||
|
|
||||||
|
Dir.entries(dir).each do |fn|
|
||||||
|
next if fn.starts_with? "."
|
||||||
|
path = File.join dir, fn
|
||||||
|
if File.directory? path
|
||||||
|
title = Title.new path, @id, storage, @logger, library
|
||||||
|
next if title.entries.size == 0 && title.titles.size == 0
|
||||||
|
@library.title_hash[title.id] = title
|
||||||
|
@title_ids << title.id
|
||||||
|
next
|
||||||
|
end
|
||||||
|
if [".zip", ".cbz"].includes? File.extname path
|
||||||
|
zip_exception = validate_zip path
|
||||||
|
unless zip_exception.nil?
|
||||||
|
@logger.warn "File #{path} is corrupted or is not a valid zip " \
|
||||||
|
"archive. Ignoring it."
|
||||||
|
@logger.debug "Zip error: #{zip_exception}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
entry = Entry.new path, self, @id, storage
|
||||||
|
@entries << entry if entry.pages > 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
mtimes = [@mtime]
|
||||||
|
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime }
|
||||||
|
mtimes += @entries.map { |e| e.mtime }
|
||||||
|
@mtime = mtimes.max
|
||||||
|
|
||||||
|
@title_ids.sort! do |a, b|
|
||||||
|
compare_alphanumerically @library.title_hash[a].title,
|
||||||
|
@library.title_hash[b].title
|
||||||
|
end
|
||||||
|
@entries.sort! do |a, b|
|
||||||
|
compare_alphanumerically a.title, b.title
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(json : JSON::Builder)
|
||||||
|
json.object do
|
||||||
|
{% for str in ["dir", "title", "id", "encoded_title"] %}
|
||||||
|
json.field {{str}}, @{{str.id}}
|
||||||
|
{% end %}
|
||||||
|
json.field "display_name", display_name
|
||||||
|
json.field "cover_url", cover_url
|
||||||
|
json.field "mtime" { json.number @mtime.to_unix }
|
||||||
|
json.field "titles" do
|
||||||
|
json.raw self.titles.to_json
|
||||||
|
end
|
||||||
|
json.field "entries" do
|
||||||
|
json.raw @entries.to_json
|
||||||
|
end
|
||||||
|
json.field "parents" do
|
||||||
|
json.array do
|
||||||
|
self.parents.each do |title|
|
||||||
|
json.object do
|
||||||
|
json.field "title", title.title
|
||||||
|
json.field "id", title.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def titles
|
||||||
|
@title_ids.map { |tid| @library.get_title! tid }
|
||||||
|
end
|
||||||
|
|
||||||
|
def parents
|
||||||
|
ary = [] of Title
|
||||||
|
tid = @parent_id
|
||||||
|
while !tid.empty?
|
||||||
|
title = @library.get_title! tid
|
||||||
|
ary << title
|
||||||
|
tid = title.parent_id
|
||||||
|
end
|
||||||
|
ary
|
||||||
|
end
|
||||||
|
|
||||||
|
def size
|
||||||
|
@entries.size + @title_ids.size
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_entry(eid)
|
||||||
|
@entries.find { |e| e.id == eid }
|
||||||
|
end
|
||||||
|
|
||||||
|
def display_name
|
||||||
|
dn = @title
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info_dn = info.display_name
|
||||||
|
dn = info_dn unless info_dn.empty?
|
||||||
|
end
|
||||||
|
dn
|
||||||
|
end
|
||||||
|
|
||||||
|
def encoded_display_name
|
||||||
|
URI.encode display_name
|
||||||
|
end
|
||||||
|
|
||||||
|
def display_name(entry_name)
|
||||||
|
dn = entry_name
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info_dn = info.entry_display_name[entry_name]?
|
||||||
|
unless info_dn.nil? || info_dn.empty?
|
||||||
|
dn = info_dn
|
||||||
|
end
|
||||||
|
end
|
||||||
|
dn
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_display_name(dn)
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info.display_name = dn
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_display_name(entry_name : String, dn)
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info.entry_display_name[entry_name] = dn
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cover_url
|
||||||
|
url = "img/icon.png"
|
||||||
|
if @entries.size > 0
|
||||||
|
url = @entries[0].cover_url
|
||||||
|
end
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info_url = info.cover_url
|
||||||
|
unless info_url.nil? || info_url.empty?
|
||||||
|
url = info_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
url
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_cover_url(url : String)
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info.cover_url = url
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_cover_url(entry_name : String, url : String)
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info.entry_cover_url[entry_name] = url
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set the reading progress of all entries and nested libraries to 100%
|
||||||
|
def read_all(username)
|
||||||
|
@entries.each do |e|
|
||||||
|
save_progress username, e.title, e.pages
|
||||||
|
end
|
||||||
|
titles.each do |t|
|
||||||
|
t.read_all username
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set the reading progress of all entries and nested libraries to 0%
|
||||||
|
def unread_all(username)
|
||||||
|
@entries.each do |e|
|
||||||
|
save_progress username, e.title, 0
|
||||||
|
end
|
||||||
|
titles.each do |t|
|
||||||
|
t.unread_all username
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# For backward backward compatibility with v0.1.0, we save entry titles
|
||||||
|
# instead of IDs in info.json
|
||||||
|
def save_progress(username, entry, page)
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
if info.progress[username]?.nil?
|
||||||
|
info.progress[username] = {entry => page}
|
||||||
|
else
|
||||||
|
info.progress[username][entry] = page
|
||||||
|
end
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_progress(username, entry)
|
||||||
|
progress = 0
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
unless info.progress[username]?.nil? ||
|
||||||
|
info.progress[username][entry]?.nil?
|
||||||
|
progress = info.progress[username][entry]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
progress
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_percetage(username, entry)
|
||||||
|
page = load_progress username, entry
|
||||||
|
entry_obj = @entries.find { |e| e.title == entry }
|
||||||
|
return 0.0 if entry_obj.nil?
|
||||||
|
page / entry_obj.pages
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_percetage(username)
|
||||||
|
return 0.0 if @entries.empty?
|
||||||
|
read_pages = total_pages = 0
|
||||||
|
@entries.each do |e|
|
||||||
|
read_pages += load_progress username, e.title
|
||||||
|
total_pages += e.pages
|
||||||
|
end
|
||||||
|
read_pages / total_pages
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_entry(current_entry_obj)
|
||||||
|
idx = @entries.index current_entry_obj
|
||||||
|
return nil if idx.nil? || idx == @entries.size - 1
|
||||||
|
@entries[idx + 1]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class TitleInfo
|
||||||
|
include JSON::Serializable
|
||||||
|
|
||||||
|
property comment = "Generated by Mango. DO NOT EDIT!"
|
||||||
|
property progress = {} of String => Hash(String, Int32)
|
||||||
|
property display_name = ""
|
||||||
|
property entry_display_name = {} of String => String
|
||||||
|
property cover_url = ""
|
||||||
|
property entry_cover_url = {} of String => String
|
||||||
|
|
||||||
|
@[JSON::Field(ignore: true)]
|
||||||
|
property dir : String = ""
|
||||||
|
|
||||||
|
@@mutex_hash = {} of String => Mutex
|
||||||
|
|
||||||
|
def self.new(dir, &)
|
||||||
|
if @@mutex_hash[dir]?
|
||||||
|
mutex = @@mutex_hash[dir]
|
||||||
|
else
|
||||||
|
mutex = Mutex.new
|
||||||
|
@@mutex_hash[dir] = mutex
|
||||||
|
end
|
||||||
|
mutex.synchronize do
|
||||||
|
instance = TitleInfo.allocate
|
||||||
|
json_path = File.join dir, "info.json"
|
||||||
|
if File.exists? json_path
|
||||||
|
instance = TitleInfo.from_json File.read json_path
|
||||||
|
end
|
||||||
|
instance.dir = dir
|
||||||
|
yield instance
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def save
|
||||||
|
json_path = File.join @dir, "info.json"
|
||||||
|
File.write json_path, self.to_pretty_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Library
|
||||||
|
property dir : String, title_ids : Array(String), scan_interval : Int32,
|
||||||
|
logger : Logger, storage : Storage, title_hash : Hash(String, Title)
|
||||||
|
|
||||||
|
def initialize(@dir, @scan_interval, @logger, @storage)
|
||||||
|
# explicitly initialize @titles to bypass the compiler check. it will
|
||||||
|
# be filled with actual Titles in the `scan` call below
|
||||||
|
@title_ids = [] of String
|
||||||
|
@title_hash = {} of String => Title
|
||||||
|
|
||||||
|
return scan if @scan_interval < 1
|
||||||
|
spawn do
|
||||||
|
loop do
|
||||||
|
start = Time.local
|
||||||
|
scan
|
||||||
|
ms = (Time.local - start).total_milliseconds
|
||||||
|
@logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
|
||||||
|
sleep @scan_interval * 60
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def titles
|
||||||
|
@title_ids.map { |tid| self.get_title!(tid) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(json : JSON::Builder)
|
||||||
|
json.object do
|
||||||
|
json.field "dir", @dir
|
||||||
|
json.field "titles" do
|
||||||
|
json.raw self.titles.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_title(tid)
|
||||||
|
@title_hash[tid]?
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_title!(tid)
|
||||||
|
@title_hash[tid]
|
||||||
|
end
|
||||||
|
|
||||||
|
def scan
|
||||||
|
unless Dir.exists? @dir
|
||||||
|
@logger.info "The library directory #{@dir} does not exist. " \
|
||||||
|
"Attempting to create it"
|
||||||
|
Dir.mkdir_p @dir
|
||||||
|
end
|
||||||
|
@title_ids.clear
|
||||||
|
(Dir.entries @dir)
|
||||||
|
.select { |fn| !fn.starts_with? "." }
|
||||||
|
.map { |fn| File.join @dir, fn }
|
||||||
|
.select { |path| File.directory? path }
|
||||||
|
.map { |path| Title.new path, "", @storage, @logger, self }
|
||||||
|
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
||||||
|
.sort { |a, b| a.title <=> b.title }
|
||||||
|
.each do |title|
|
||||||
|
@title_hash[title.id] = title
|
||||||
|
@title_ids << title.id
|
||||||
|
end
|
||||||
|
@logger.debug "Scan completed"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
require "digest"
|
|
||||||
|
|
||||||
require "./entry"
|
|
||||||
require "./types"
|
|
||||||
|
|
||||||
# Base class for an entry in the LRU cache.
|
|
||||||
# There are two ways to use it:
|
|
||||||
# 1. Use it as it is by instantiating with the appropriate `SaveT` and
|
|
||||||
# `ReturnT`. Note that in this case, `SaveT` and `ReturnT` must be the
|
|
||||||
# same type. That is, the input value will be stored as it is without
|
|
||||||
# any transformation.
|
|
||||||
# 2. You can also subclass it and provide custom implementations for
|
|
||||||
# `to_save_t` and `to_return_t`. This allows you to transform and store
|
|
||||||
# the input value to a different type. See `SortedEntriesCacheEntry` as
|
|
||||||
# an example.
|
|
||||||
private class CacheEntry(SaveT, ReturnT)
|
|
||||||
getter key : String, atime : Time
|
|
||||||
|
|
||||||
@value : SaveT
|
|
||||||
|
|
||||||
def initialize(@key : String, value : ReturnT)
|
|
||||||
@atime = @ctime = Time.utc
|
|
||||||
@value = self.class.to_save_t value
|
|
||||||
end
|
|
||||||
|
|
||||||
def value
|
|
||||||
@atime = Time.utc
|
|
||||||
self.class.to_return_t @value
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.to_save_t(value : ReturnT)
|
|
||||||
value
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.to_return_t(value : SaveT)
|
|
||||||
value
|
|
||||||
end
|
|
||||||
|
|
||||||
def instance_size
|
|
||||||
instance_sizeof(CacheEntry(SaveT, ReturnT)) + # sizeof itself
|
|
||||||
instance_sizeof(String) + @key.bytesize + # allocated memory for @key
|
|
||||||
@value.instance_size
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class SortedEntriesCacheEntry < CacheEntry(Array(String), Array(Entry))
|
|
||||||
def self.to_save_t(value : Array(Entry))
|
|
||||||
value.map &.id
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.to_return_t(value : Array(String))
|
|
||||||
ids_to_entries value
|
|
||||||
end
|
|
||||||
|
|
||||||
private def self.ids_to_entries(ids : Array(String))
|
|
||||||
e_map = Library.default.deep_entries.to_h { |entry| {entry.id, entry} }
|
|
||||||
entries = [] of Entry
|
|
||||||
begin
|
|
||||||
ids.each do |id|
|
|
||||||
entries << e_map[id]
|
|
||||||
end
|
|
||||||
return entries if ids.size == entries.size
|
|
||||||
rescue
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def instance_size
|
|
||||||
instance_sizeof(SortedEntriesCacheEntry) + # sizeof itself
|
|
||||||
instance_sizeof(String) + @key.bytesize + # allocated memory for @key
|
|
||||||
@value.size * (instance_sizeof(String) + sizeof(String)) +
|
|
||||||
@value.sum(&.bytesize) # elements in Array(String)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.gen_key(book_id : String, username : String,
|
|
||||||
entries : Array(Entry), opt : SortOptions?)
|
|
||||||
entries_sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
|
|
||||||
user_context = opt && opt.method == SortMethod::Progress ? username : ""
|
|
||||||
sig = Digest::SHA1.hexdigest (book_id + entries_sig + user_context +
|
|
||||||
(opt ? opt.to_tuple.to_s : "nil"))
|
|
||||||
"#{sig}:sorted_entries"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class String
|
|
||||||
def instance_size
|
|
||||||
instance_sizeof(String) + bytesize
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
struct Tuple(*T)
|
|
||||||
def instance_size
|
|
||||||
sizeof(T) + # total size of non-reference types
|
|
||||||
self.sum do |e|
|
|
||||||
next 0 unless e.is_a? Reference
|
|
||||||
if e.responds_to? :instance_size
|
|
||||||
e.instance_size
|
|
||||||
else
|
|
||||||
instance_sizeof(typeof(e))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
alias CacheableType = Array(Entry) | String | Tuple(String, Int32)
|
|
||||||
alias CacheEntryType = SortedEntriesCacheEntry |
|
|
||||||
CacheEntry(String, String) |
|
|
||||||
CacheEntry(Tuple(String, Int32), Tuple(String, Int32))
|
|
||||||
|
|
||||||
def generate_cache_entry(key : String, value : CacheableType)
|
|
||||||
if value.is_a? Array(Entry)
|
|
||||||
SortedEntriesCacheEntry.new key, value
|
|
||||||
else
|
|
||||||
CacheEntry(typeof(value), typeof(value)).new key, value
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# LRU Cache
|
|
||||||
class LRUCache
|
|
||||||
@@limit : Int128 = Int128.new 0
|
|
||||||
@@should_log = true
|
|
||||||
# key => entry
|
|
||||||
@@cache = {} of String => CacheEntryType
|
|
||||||
|
|
||||||
def self.enabled
|
|
||||||
Config.current.cache_enabled
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.init
|
|
||||||
cache_size = Config.current.cache_size_mbs
|
|
||||||
@@limit = Int128.new cache_size * 1024 * 1024 if enabled
|
|
||||||
@@should_log = Config.current.cache_log_enabled
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.get(key : String)
|
|
||||||
return unless enabled
|
|
||||||
entry = @@cache[key]?
|
|
||||||
if @@should_log
|
|
||||||
Logger.debug "LRUCache #{entry.nil? ? "miss" : "hit"} #{key}"
|
|
||||||
end
|
|
||||||
return entry.value unless entry.nil?
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.set(cache_entry : CacheEntryType)
|
|
||||||
return unless enabled
|
|
||||||
key = cache_entry.key
|
|
||||||
@@cache[key] = cache_entry
|
|
||||||
Logger.debug "LRUCache cached #{key}" if @@should_log
|
|
||||||
remove_least_recent_access
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.invalidate(key : String)
|
|
||||||
return unless enabled
|
|
||||||
@@cache.delete key
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.print
|
|
||||||
return unless @@should_log
|
|
||||||
sum = @@cache.sum { |_, entry| entry.instance_size }
|
|
||||||
Logger.debug "---- LRU Cache ----"
|
|
||||||
Logger.debug "Size: #{sum} Bytes"
|
|
||||||
Logger.debug "List:"
|
|
||||||
@@cache.each do |k, v|
|
|
||||||
Logger.debug "#{k} | #{v.atime} | #{v.instance_size}"
|
|
||||||
end
|
|
||||||
Logger.debug "-------------------"
|
|
||||||
end
|
|
||||||
|
|
||||||
private def self.is_cache_full
|
|
||||||
sum = @@cache.sum { |_, entry| entry.instance_size }
|
|
||||||
sum > @@limit
|
|
||||||
end
|
|
||||||
|
|
||||||
private def self.remove_least_recent_access
|
|
||||||
if @@should_log && is_cache_full
|
|
||||||
Logger.debug "Removing entries from LRUCache"
|
|
||||||
end
|
|
||||||
while is_cache_full && @@cache.size > 0
|
|
||||||
min_tuple = @@cache.min_by { |_, entry| entry.atime }
|
|
||||||
min_key = min_tuple[0]
|
|
||||||
min_entry = min_tuple[1]
|
|
||||||
|
|
||||||
Logger.debug " \
|
|
||||||
Target: #{min_key}, \
|
|
||||||
Last Access Time: #{min_entry.atime}" if @@should_log
|
|
||||||
invalidate min_key
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
require "image_size"
|
|
||||||
require "yaml"
|
|
||||||
|
|
||||||
class Entry
|
|
||||||
include YAML::Serializable
|
|
||||||
|
|
||||||
getter zip_path : String, book : Title, title : String,
|
|
||||||
size : String, pages : Int32, id : String, encoded_path : String,
|
|
||||||
encoded_title : String, mtime : Time, err_msg : String?
|
|
||||||
|
|
||||||
def initialize(@zip_path, @book)
|
|
||||||
storage = Storage.default
|
|
||||||
@encoded_path = URI.encode @zip_path
|
|
||||||
@title = File.basename @zip_path, File.extname @zip_path
|
|
||||||
@encoded_title = URI.encode @title
|
|
||||||
@size = (File.size @zip_path).humanize_bytes
|
|
||||||
id = storage.get_entry_id @zip_path, File.signature(@zip_path)
|
|
||||||
if id.nil?
|
|
||||||
id = random_str
|
|
||||||
storage.insert_entry_id({
|
|
||||||
path: @zip_path,
|
|
||||||
id: id,
|
|
||||||
signature: File.signature(@zip_path).to_s,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
@id = id
|
|
||||||
@mtime = File.info(@zip_path).modification_time
|
|
||||||
|
|
||||||
unless File.readable? @zip_path
|
|
||||||
@err_msg = "File #{@zip_path} is not readable."
|
|
||||||
Logger.warn "#{@err_msg} Please make sure the " \
|
|
||||||
"file permission is configured correctly."
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
archive_exception = validate_archive @zip_path
|
|
||||||
unless archive_exception.nil?
|
|
||||||
@err_msg = "Archive error: #{archive_exception}"
|
|
||||||
Logger.warn "Unable to extract archive #{@zip_path}. " \
|
|
||||||
"Ignoring it. #{@err_msg}"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
file = ArchiveFile.new @zip_path
|
|
||||||
@pages = file.entries.count do |e|
|
|
||||||
SUPPORTED_IMG_TYPES.includes? \
|
|
||||||
MIME.from_filename? e.filename
|
|
||||||
end
|
|
||||||
file.close
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_json(*, slim = false)
|
|
||||||
JSON.build do |json|
|
|
||||||
json.object do
|
|
||||||
{% for str in ["zip_path", "title", "size", "id"] %}
|
|
||||||
json.field {{str}}, @{{str.id}}
|
|
||||||
{% end %}
|
|
||||||
json.field "title_id", @book.id
|
|
||||||
json.field "pages" { json.number @pages }
|
|
||||||
unless slim
|
|
||||||
json.field "display_name", @book.display_name @title
|
|
||||||
json.field "cover_url", cover_url
|
|
||||||
json.field "mtime" { json.number @mtime.to_unix }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def display_name
|
|
||||||
@book.display_name @title
|
|
||||||
end
|
|
||||||
|
|
||||||
def encoded_display_name
|
|
||||||
URI.encode display_name
|
|
||||||
end
|
|
||||||
|
|
||||||
def cover_url
|
|
||||||
return "#{Config.current.base_url}img/icon.png" if @err_msg
|
|
||||||
|
|
||||||
unless @book.entry_cover_url_cache
|
|
||||||
TitleInfo.new @book.dir do |info|
|
|
||||||
@book.entry_cover_url_cache = info.entry_cover_url
|
|
||||||
end
|
|
||||||
end
|
|
||||||
entry_cover_url = @book.entry_cover_url_cache
|
|
||||||
|
|
||||||
url = "#{Config.current.base_url}api/cover/#{@book.id}/#{@id}"
|
|
||||||
if entry_cover_url
|
|
||||||
info_url = entry_cover_url[@title]?
|
|
||||||
unless info_url.nil? || info_url.empty?
|
|
||||||
url = File.join Config.current.base_url, info_url
|
|
||||||
end
|
|
||||||
end
|
|
||||||
url
|
|
||||||
end
|
|
||||||
|
|
||||||
private def sorted_archive_entries
|
|
||||||
ArchiveFile.open @zip_path do |file|
|
|
||||||
entries = file.entries
|
|
||||||
.select { |e|
|
|
||||||
SUPPORTED_IMG_TYPES.includes? \
|
|
||||||
MIME.from_filename? e.filename
|
|
||||||
}
|
|
||||||
.sort! { |a, b|
|
|
||||||
compare_numerically a.filename, b.filename
|
|
||||||
}
|
|
||||||
yield file, entries
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def read_page(page_num)
|
|
||||||
raise "Unreadble archive. #{@err_msg}" if @err_msg
|
|
||||||
img = nil
|
|
||||||
sorted_archive_entries do |file, entries|
|
|
||||||
page = entries[page_num - 1]
|
|
||||||
data = file.read_entry page
|
|
||||||
if data
|
|
||||||
img = Image.new data, MIME.from_filename(page.filename), page.filename,
|
|
||||||
data.size
|
|
||||||
end
|
|
||||||
end
|
|
||||||
img
|
|
||||||
end
|
|
||||||
|
|
||||||
def page_dimensions
|
|
||||||
sizes = [] of Hash(String, Int32)
|
|
||||||
sorted_archive_entries do |file, entries|
|
|
||||||
entries.each_with_index do |e, i|
|
|
||||||
begin
|
|
||||||
data = file.read_entry(e).not_nil!
|
|
||||||
size = ImageSize.get data
|
|
||||||
sizes << {
|
|
||||||
"width" => size.width,
|
|
||||||
"height" => size.height,
|
|
||||||
}
|
|
||||||
rescue e
|
|
||||||
Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
|
|
||||||
sizes << {"width" => 1000_i32, "height" => 1000_i32}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
sizes
|
|
||||||
end
|
|
||||||
|
|
||||||
def next_entry(username)
|
|
||||||
entries = @book.sorted_entries username
|
|
||||||
idx = entries.index self
|
|
||||||
return nil if idx.nil? || idx == entries.size - 1
|
|
||||||
entries[idx + 1]
|
|
||||||
end
|
|
||||||
|
|
||||||
def previous_entry(username)
|
|
||||||
entries = @book.sorted_entries username
|
|
||||||
idx = entries.index self
|
|
||||||
return nil if idx.nil? || idx == 0
|
|
||||||
entries[idx - 1]
|
|
||||||
end
|
|
||||||
|
|
||||||
def date_added
|
|
||||||
date_added = nil
|
|
||||||
TitleInfo.new @book.dir do |info|
|
|
||||||
info_da = info.date_added[@title]?
|
|
||||||
if info_da.nil?
|
|
||||||
date_added = info.date_added[@title] = ctime @zip_path
|
|
||||||
info.save
|
|
||||||
else
|
|
||||||
date_added = info_da
|
|
||||||
end
|
|
||||||
end
|
|
||||||
date_added.not_nil! # is it ok to set not_nil! here?
|
|
||||||
end
|
|
||||||
|
|
||||||
# For backward backward compatibility with v0.1.0, we save entry titles
|
|
||||||
# instead of IDs in info.json
|
|
||||||
def save_progress(username, page)
|
|
||||||
LRUCache.invalidate "#{@book.id}:#{username}:progress_sum"
|
|
||||||
@book.parents.each do |parent|
|
|
||||||
LRUCache.invalidate "#{parent.id}:#{username}:progress_sum"
|
|
||||||
end
|
|
||||||
[false, true].each do |ascend|
|
|
||||||
sorted_entries_cache_key = SortedEntriesCacheEntry.gen_key @book.id,
|
|
||||||
username, @book.entries, SortOptions.new(SortMethod::Progress, ascend)
|
|
||||||
LRUCache.invalidate sorted_entries_cache_key
|
|
||||||
end
|
|
||||||
|
|
||||||
TitleInfo.new @book.dir do |info|
|
|
||||||
if info.progress[username]?.nil?
|
|
||||||
info.progress[username] = {@title => page}
|
|
||||||
else
|
|
||||||
info.progress[username][@title] = page
|
|
||||||
end
|
|
||||||
# save last_read timestamp
|
|
||||||
if info.last_read[username]?.nil?
|
|
||||||
info.last_read[username] = {@title => Time.utc}
|
|
||||||
else
|
|
||||||
info.last_read[username][@title] = Time.utc
|
|
||||||
end
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_progress(username)
|
|
||||||
progress = 0
|
|
||||||
TitleInfo.new @book.dir do |info|
|
|
||||||
unless info.progress[username]?.nil? ||
|
|
||||||
info.progress[username][@title]?.nil?
|
|
||||||
progress = info.progress[username][@title]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
[progress, @pages].min
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_percentage(username)
|
|
||||||
page = load_progress username
|
|
||||||
page / @pages
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_last_read(username)
|
|
||||||
last_read = nil
|
|
||||||
TitleInfo.new @book.dir do |info|
|
|
||||||
unless info.last_read[username]?.nil? ||
|
|
||||||
info.last_read[username][@title]?.nil?
|
|
||||||
last_read = info.last_read[username][@title]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
last_read
|
|
||||||
end
|
|
||||||
|
|
||||||
def finished?(username)
|
|
||||||
load_progress(username) == @pages
|
|
||||||
end
|
|
||||||
|
|
||||||
def started?(username)
|
|
||||||
load_progress(username) > 0
|
|
||||||
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
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
class Library
|
|
||||||
include YAML::Serializable
|
|
||||||
|
|
||||||
getter dir : String, title_ids : Array(String),
|
|
||||||
title_hash : Hash(String, Title)
|
|
||||||
|
|
||||||
use_default
|
|
||||||
|
|
||||||
def save_instance
|
|
||||||
path = Config.current.library_cache_path
|
|
||||||
Logger.debug "Caching library to #{path}"
|
|
||||||
|
|
||||||
writer = Compress::Gzip::Writer.new path,
|
|
||||||
Compress::Gzip::BEST_COMPRESSION
|
|
||||||
writer.write self.to_yaml.to_slice
|
|
||||||
writer.close
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.load_instance
|
|
||||||
path = Config.current.library_cache_path
|
|
||||||
return unless File.exists? path
|
|
||||||
|
|
||||||
Logger.debug "Loading cached library from #{path}"
|
|
||||||
|
|
||||||
begin
|
|
||||||
Compress::Gzip::Reader.open path do |content|
|
|
||||||
@@default = Library.from_yaml content
|
|
||||||
end
|
|
||||||
Library.default.register_jobs
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
@dir = Config.current.library_path
|
|
||||||
# explicitly initialize @titles to bypass the compiler check. it will
|
|
||||||
# be filled with actual Titles in the `scan` call below
|
|
||||||
@title_ids = [] of String
|
|
||||||
@title_hash = {} of String => Title
|
|
||||||
|
|
||||||
@entries_count = 0
|
|
||||||
@thumbnails_count = 0
|
|
||||||
|
|
||||||
register_jobs
|
|
||||||
end
|
|
||||||
|
|
||||||
protected def register_jobs
|
|
||||||
register_mime_types
|
|
||||||
|
|
||||||
scan_interval = Config.current.scan_interval_minutes
|
|
||||||
if scan_interval < 1
|
|
||||||
scan
|
|
||||||
else
|
|
||||||
spawn do
|
|
||||||
loop do
|
|
||||||
start = Time.local
|
|
||||||
scan
|
|
||||||
ms = (Time.local - start).total_milliseconds
|
|
||||||
Logger.debug "Library initialized in #{ms}ms"
|
|
||||||
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
|
|
||||||
end
|
|
||||||
|
|
||||||
def titles
|
|
||||||
@title_ids.map { |tid| self.get_title!(tid) }
|
|
||||||
end
|
|
||||||
|
|
||||||
def sorted_titles(username, opt : SortOptions? = nil)
|
|
||||||
if opt.nil?
|
|
||||||
opt = SortOptions.from_info_json @dir, username
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper function from src/util/util.cr
|
|
||||||
sort_titles titles, opt.not_nil!, username
|
|
||||||
end
|
|
||||||
|
|
||||||
def deep_titles
|
|
||||||
titles + titles.flat_map &.deep_titles
|
|
||||||
end
|
|
||||||
|
|
||||||
def deep_entries
|
|
||||||
titles.flat_map &.deep_entries
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_json(*, slim = false, depth = -1)
|
|
||||||
JSON.build do |json|
|
|
||||||
json.object do
|
|
||||||
json.field "dir", @dir
|
|
||||||
json.field "titles" do
|
|
||||||
json.array do
|
|
||||||
self.titles.each do |title|
|
|
||||||
json.raw title.build_json(slim: slim, depth: depth)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_title(tid)
|
|
||||||
@title_hash[tid]?
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_title!(tid)
|
|
||||||
@title_hash[tid]
|
|
||||||
end
|
|
||||||
|
|
||||||
def scan
|
|
||||||
start = Time.local
|
|
||||||
unless Dir.exists? @dir
|
|
||||||
Logger.info "The library directory #{@dir} does not exist. " \
|
|
||||||
"Attempting to create it"
|
|
||||||
Dir.mkdir_p @dir
|
|
||||||
end
|
|
||||||
|
|
||||||
storage = Storage.new auto_close: false
|
|
||||||
|
|
||||||
examine_context : ExamineContext = {
|
|
||||||
cached_contents_signature: {} of String => String,
|
|
||||||
deleted_title_ids: [] of String,
|
|
||||||
deleted_entry_ids: [] of String,
|
|
||||||
}
|
|
||||||
|
|
||||||
@title_ids.select! do |title_id|
|
|
||||||
title = @title_hash[title_id]
|
|
||||||
existence = title.examine examine_context
|
|
||||||
unless existence
|
|
||||||
examine_context["deleted_title_ids"].concat [title_id] +
|
|
||||||
title.deep_titles.map &.id
|
|
||||||
examine_context["deleted_entry_ids"].concat title.deep_entries.map &.id
|
|
||||||
end
|
|
||||||
existence
|
|
||||||
end
|
|
||||||
remained_title_dirs = @title_ids.map { |id| title_hash[id].dir }
|
|
||||||
examine_context["deleted_title_ids"].each do |title_id|
|
|
||||||
@title_hash.delete title_id
|
|
||||||
end
|
|
||||||
|
|
||||||
cache = examine_context["cached_contents_signature"]
|
|
||||||
(Dir.entries @dir)
|
|
||||||
.select { |fn| !fn.starts_with? "." }
|
|
||||||
.map { |fn| File.join @dir, fn }
|
|
||||||
.select { |path| !(remained_title_dirs.includes? path) }
|
|
||||||
.select { |path| File.directory? path }
|
|
||||||
.map { |path| Title.new path, "", cache }
|
|
||||||
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
|
||||||
.sort! { |a, b| a.title <=> b.title }
|
|
||||||
.each do |title|
|
|
||||||
@title_hash[title.id] = title
|
|
||||||
@title_ids << title.id
|
|
||||||
end
|
|
||||||
|
|
||||||
storage.bulk_insert_ids
|
|
||||||
storage.close
|
|
||||||
|
|
||||||
ms = (Time.local - start).total_milliseconds
|
|
||||||
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
|
|
||||||
|
|
||||||
Storage.default.mark_unavailable examine_context["deleted_entry_ids"],
|
|
||||||
examine_context["deleted_title_ids"]
|
|
||||||
|
|
||||||
spawn do
|
|
||||||
save_instance
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_continue_reading_entries(username)
|
|
||||||
cr_entries = deep_titles
|
|
||||||
.map(&.get_last_read_entry username)
|
|
||||||
# Select elements with type `Entry` from the array and ignore all `Nil`s
|
|
||||||
.select(Entry)[0...ENTRIES_IN_HOME_SECTIONS]
|
|
||||||
.map { |e|
|
|
||||||
# Get the last read time of the entry. If it hasn't been started, get
|
|
||||||
# the last read time of the previous entry
|
|
||||||
last_read = e.load_last_read username
|
|
||||||
pe = e.previous_entry username
|
|
||||||
if last_read.nil? && pe
|
|
||||||
last_read = pe.load_last_read username
|
|
||||||
end
|
|
||||||
{
|
|
||||||
entry: e,
|
|
||||||
percentage: e.load_percentage(username),
|
|
||||||
last_read: last_read,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Sort by by last_read, most recent first (nils at the end)
|
|
||||||
cr_entries.sort { |a, b|
|
|
||||||
next 0 if a[:last_read].nil? && b[:last_read].nil?
|
|
||||||
next 1 if a[:last_read].nil?
|
|
||||||
next -1 if b[:last_read].nil?
|
|
||||||
b[:last_read].not_nil! <=> a[:last_read].not_nil!
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
alias RA = NamedTuple(
|
|
||||||
entry: Entry,
|
|
||||||
percentage: Float64,
|
|
||||||
grouped_count: Int32)
|
|
||||||
|
|
||||||
def get_recently_added_entries(username)
|
|
||||||
recently_added = [] of RA
|
|
||||||
last_date_added = nil
|
|
||||||
|
|
||||||
titles.flat_map(&.deep_entries_with_date_added)
|
|
||||||
.select(&.[:date_added].> 1.month.ago)
|
|
||||||
.sort! { |a, b| b[:date_added] <=> a[:date_added] }
|
|
||||||
.each do |e|
|
|
||||||
break if recently_added.size > 12
|
|
||||||
last = recently_added.last?
|
|
||||||
if last && e[:entry].book.id == last[:entry].book.id &&
|
|
||||||
(e[:date_added] - last_date_added.not_nil!).abs < 1.day
|
|
||||||
# A NamedTuple is immutable, so we have to cast it to a Hash first
|
|
||||||
last_hash = last.to_h
|
|
||||||
count = last_hash[:grouped_count].as(Int32)
|
|
||||||
last_hash[:grouped_count] = count + 1
|
|
||||||
# Setting the percentage to a negative value will hide the
|
|
||||||
# percentage badge on the card
|
|
||||||
last_hash[:percentage] = -1.0
|
|
||||||
recently_added[recently_added.size - 1] = RA.from last_hash
|
|
||||||
else
|
|
||||||
last_date_added = e[:date_added]
|
|
||||||
recently_added << {
|
|
||||||
entry: e[:entry],
|
|
||||||
percentage: e[:entry].load_percentage(username),
|
|
||||||
grouped_count: 1,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
recently_added[0...ENTRIES_IN_HOME_SECTIONS]
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_start_reading_titles(username)
|
|
||||||
# Here we are not using `deep_titles` as it may cause unexpected behaviors
|
|
||||||
# For example, consider the following nested titles:
|
|
||||||
# - One Puch Man
|
|
||||||
# - Vol. 1
|
|
||||||
# - Vol. 2
|
|
||||||
# If we use `deep_titles`, the start reading section might include `Vol. 2`
|
|
||||||
# when the user hasn't started `Vol. 1` yet
|
|
||||||
titles
|
|
||||||
.select(&.load_percentage(username).== 0)
|
|
||||||
.sample(ENTRIES_IN_HOME_SECTIONS)
|
|
||||||
.shuffle!
|
|
||||||
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.flat_map(&.deep_entries).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 1.seconds
|
|
||||||
end
|
|
||||||
@thumbnails_count += 1
|
|
||||||
end
|
|
||||||
Logger.info "Thumbnail generation finished"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,569 +0,0 @@
|
|||||||
require "digest"
|
|
||||||
require "../archive"
|
|
||||||
|
|
||||||
class Title
|
|
||||||
include YAML::Serializable
|
|
||||||
|
|
||||||
getter dir : String, parent_id : String, title_ids : Array(String),
|
|
||||||
entries : Array(Entry), title : String, id : String,
|
|
||||||
encoded_title : String, mtime : Time, signature : UInt64,
|
|
||||||
entry_cover_url_cache : Hash(String, String)?
|
|
||||||
setter entry_cover_url_cache : Hash(String, String)?
|
|
||||||
|
|
||||||
@[YAML::Field(ignore: true)]
|
|
||||||
@entry_display_name_cache : Hash(String, String)?
|
|
||||||
@[YAML::Field(ignore: true)]
|
|
||||||
@entry_cover_url_cache : Hash(String, String)?
|
|
||||||
@[YAML::Field(ignore: true)]
|
|
||||||
@cached_display_name : String?
|
|
||||||
@[YAML::Field(ignore: true)]
|
|
||||||
@cached_cover_url : String?
|
|
||||||
|
|
||||||
def initialize(@dir : String, @parent_id, cache = {} of String => String)
|
|
||||||
storage = Storage.default
|
|
||||||
@signature = Dir.signature dir
|
|
||||||
id = storage.get_title_id dir, signature
|
|
||||||
if id.nil?
|
|
||||||
id = random_str
|
|
||||||
storage.insert_title_id({
|
|
||||||
path: dir,
|
|
||||||
id: id,
|
|
||||||
signature: signature.to_s,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
@id = id
|
|
||||||
@contents_signature = Dir.contents_signature dir, cache
|
|
||||||
@title = File.basename dir
|
|
||||||
@encoded_title = URI.encode @title
|
|
||||||
@title_ids = [] of String
|
|
||||||
@entries = [] of Entry
|
|
||||||
@mtime = File.info(dir).modification_time
|
|
||||||
|
|
||||||
Dir.entries(dir).each do |fn|
|
|
||||||
next if fn.starts_with? "."
|
|
||||||
path = File.join dir, fn
|
|
||||||
if File.directory? path
|
|
||||||
title = Title.new path, @id, cache
|
|
||||||
next if title.entries.size == 0 && title.titles.size == 0
|
|
||||||
Library.default.title_hash[title.id] = title
|
|
||||||
@title_ids << title.id
|
|
||||||
next
|
|
||||||
end
|
|
||||||
if is_supported_file path
|
|
||||||
entry = Entry.new path, self
|
|
||||||
@entries << entry if entry.pages > 0 || entry.err_msg
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
mtimes = [@mtime]
|
|
||||||
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
|
|
||||||
mtimes += @entries.map &.mtime
|
|
||||||
@mtime = mtimes.max
|
|
||||||
|
|
||||||
@title_ids.sort! do |a, b|
|
|
||||||
compare_numerically Library.default.title_hash[a].title,
|
|
||||||
Library.default.title_hash[b].title
|
|
||||||
end
|
|
||||||
sorter = ChapterSorter.new @entries.map &.title
|
|
||||||
@entries.sort! do |a, b|
|
|
||||||
sorter.compare a.title, b.title
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Utility method used in library rescanning.
|
|
||||||
# - When the title does not exist on the file system anymore, return false
|
|
||||||
# and let it be deleted from the library instance
|
|
||||||
# - When the title exists, but its contents signature is now different from
|
|
||||||
# the cache, it means some of its content (nested titles or entries)
|
|
||||||
# has been added, deleted, or renamed. In this case we update its
|
|
||||||
# contents signature and instance variables
|
|
||||||
# - When the title exists and its contents signature is still the same, we
|
|
||||||
# return true so it can be reused without rescanning
|
|
||||||
def examine(context : ExamineContext) : Bool
|
|
||||||
return false unless Dir.exists? @dir
|
|
||||||
contents_signature = Dir.contents_signature @dir,
|
|
||||||
context["cached_contents_signature"]
|
|
||||||
return true if @contents_signature == contents_signature
|
|
||||||
|
|
||||||
@contents_signature = contents_signature
|
|
||||||
@signature = Dir.signature @dir
|
|
||||||
storage = Storage.default
|
|
||||||
id = storage.get_title_id dir, signature
|
|
||||||
if id.nil?
|
|
||||||
id = random_str
|
|
||||||
storage.insert_title_id({
|
|
||||||
path: dir,
|
|
||||||
id: id,
|
|
||||||
signature: signature.to_s,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
@id = id
|
|
||||||
@mtime = File.info(@dir).modification_time
|
|
||||||
|
|
||||||
previous_titles_size = @title_ids.size
|
|
||||||
@title_ids.select! do |title_id|
|
|
||||||
title = Library.default.get_title! title_id
|
|
||||||
existence = title.examine context
|
|
||||||
unless existence
|
|
||||||
context["deleted_title_ids"].concat [title_id] +
|
|
||||||
title.deep_titles.map &.id
|
|
||||||
context["deleted_entry_ids"].concat title.deep_entries.map &.id
|
|
||||||
end
|
|
||||||
existence
|
|
||||||
end
|
|
||||||
remained_title_dirs = @title_ids.map do |title_id|
|
|
||||||
title = Library.default.get_title! title_id
|
|
||||||
title.dir
|
|
||||||
end
|
|
||||||
|
|
||||||
previous_entries_size = @entries.size
|
|
||||||
@entries.select! do |entry|
|
|
||||||
existence = File.exists? entry.zip_path
|
|
||||||
Fiber.yield
|
|
||||||
context["deleted_entry_ids"] << entry.id unless existence
|
|
||||||
existence
|
|
||||||
end
|
|
||||||
remained_entry_zip_paths = @entries.map &.zip_path
|
|
||||||
|
|
||||||
is_titles_added = false
|
|
||||||
is_entries_added = false
|
|
||||||
Dir.entries(dir).each do |fn|
|
|
||||||
next if fn.starts_with? "."
|
|
||||||
path = File.join dir, fn
|
|
||||||
if File.directory? path
|
|
||||||
next if remained_title_dirs.includes? path
|
|
||||||
title = Title.new path, @id, context["cached_contents_signature"]
|
|
||||||
next if title.entries.size == 0 && title.titles.size == 0
|
|
||||||
Library.default.title_hash[title.id] = title
|
|
||||||
@title_ids << title.id
|
|
||||||
is_titles_added = true
|
|
||||||
next
|
|
||||||
end
|
|
||||||
if is_supported_file path
|
|
||||||
next if remained_entry_zip_paths.includes? path
|
|
||||||
entry = Entry.new path, self
|
|
||||||
if entry.pages > 0 || entry.err_msg
|
|
||||||
@entries << entry
|
|
||||||
is_entries_added = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
mtimes = [@mtime]
|
|
||||||
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
|
|
||||||
mtimes += @entries.map &.mtime
|
|
||||||
@mtime = mtimes.max
|
|
||||||
|
|
||||||
if is_titles_added || previous_titles_size != @title_ids.size
|
|
||||||
@title_ids.sort! do |a, b|
|
|
||||||
compare_numerically Library.default.title_hash[a].title,
|
|
||||||
Library.default.title_hash[b].title
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if is_entries_added || previous_entries_size != @entries.size
|
|
||||||
sorter = ChapterSorter.new @entries.map &.title
|
|
||||||
@entries.sort! do |a, b|
|
|
||||||
sorter.compare a.title, b.title
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
true
|
|
||||||
end
|
|
||||||
|
|
||||||
alias SortContext = NamedTuple(username: String, opt: SortOptions)
|
|
||||||
|
|
||||||
def build_json(*, slim = false, depth = -1,
|
|
||||||
sort_context : SortContext? = nil)
|
|
||||||
JSON.build do |json|
|
|
||||||
json.object do
|
|
||||||
{% for str in ["dir", "title", "id"] %}
|
|
||||||
json.field {{str}}, @{{str.id}}
|
|
||||||
{% end %}
|
|
||||||
json.field "signature" { json.number @signature }
|
|
||||||
unless slim
|
|
||||||
json.field "display_name", display_name
|
|
||||||
json.field "cover_url", cover_url
|
|
||||||
json.field "mtime" { json.number @mtime.to_unix }
|
|
||||||
end
|
|
||||||
unless depth == 0
|
|
||||||
json.field "titles" do
|
|
||||||
json.array do
|
|
||||||
self.titles.each do |title|
|
|
||||||
json.raw title.build_json(slim: slim,
|
|
||||||
depth: depth > 0 ? depth - 1 : depth)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
json.field "entries" do
|
|
||||||
json.array do
|
|
||||||
_entries = if sort_context
|
|
||||||
sorted_entries sort_context[:username],
|
|
||||||
sort_context[:opt]
|
|
||||||
else
|
|
||||||
@entries
|
|
||||||
end
|
|
||||||
_entries.each do |entry|
|
|
||||||
json.raw entry.build_json(slim: slim)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
json.field "parents" do
|
|
||||||
json.array do
|
|
||||||
self.parents.each do |title|
|
|
||||||
json.object do
|
|
||||||
json.field "title", title.title
|
|
||||||
json.field "id", title.id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def titles
|
|
||||||
@title_ids.map { |tid| Library.default.get_title! tid }
|
|
||||||
end
|
|
||||||
|
|
||||||
# Get all entries, including entries in nested titles
|
|
||||||
def deep_entries
|
|
||||||
return @entries if title_ids.empty?
|
|
||||||
@entries + titles.flat_map &.deep_entries
|
|
||||||
end
|
|
||||||
|
|
||||||
def deep_titles
|
|
||||||
return [] of Title if titles.empty?
|
|
||||||
titles + titles.flat_map &.deep_titles
|
|
||||||
end
|
|
||||||
|
|
||||||
def parents
|
|
||||||
ary = [] of Title
|
|
||||||
tid = @parent_id
|
|
||||||
while !tid.empty?
|
|
||||||
title = Library.default.get_title! tid
|
|
||||||
ary << title
|
|
||||||
tid = title.parent_id
|
|
||||||
end
|
|
||||||
ary.reverse
|
|
||||||
end
|
|
||||||
|
|
||||||
# Returns a string the describes the content of the title
|
|
||||||
# e.g., - 3 titles and 1 entry
|
|
||||||
# - 4 entries
|
|
||||||
# - 1 title
|
|
||||||
def content_label
|
|
||||||
ary = [] of String
|
|
||||||
tsize = titles.size
|
|
||||||
esize = entries.size
|
|
||||||
|
|
||||||
ary << "#{tsize} #{tsize > 1 ? "titles" : "title"}" if tsize > 0
|
|
||||||
ary << "#{esize} #{esize > 1 ? "entries" : "entry"}" if esize > 0
|
|
||||||
ary.join " and "
|
|
||||||
end
|
|
||||||
|
|
||||||
def tags
|
|
||||||
Storage.default.get_title_tags @id
|
|
||||||
end
|
|
||||||
|
|
||||||
def add_tag(tag)
|
|
||||||
Storage.default.add_tag @id, tag
|
|
||||||
end
|
|
||||||
|
|
||||||
def delete_tag(tag)
|
|
||||||
Storage.default.delete_tag @id, tag
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_entry(eid)
|
|
||||||
@entries.find &.id.== eid
|
|
||||||
end
|
|
||||||
|
|
||||||
def display_name
|
|
||||||
cached_display_name = @cached_display_name
|
|
||||||
return cached_display_name unless cached_display_name.nil?
|
|
||||||
|
|
||||||
dn = @title
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info_dn = info.display_name
|
|
||||||
dn = info_dn unless info_dn.empty?
|
|
||||||
end
|
|
||||||
@cached_display_name = dn
|
|
||||||
dn
|
|
||||||
end
|
|
||||||
|
|
||||||
def encoded_display_name
|
|
||||||
URI.encode display_name
|
|
||||||
end
|
|
||||||
|
|
||||||
def display_name(entry_name)
|
|
||||||
unless @entry_display_name_cache
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
@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?
|
|
||||||
dn = info_dn
|
|
||||||
end
|
|
||||||
dn
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_display_name(dn)
|
|
||||||
@cached_display_name = dn
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info.display_name = dn
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_display_name(entry_name : String, dn)
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info.entry_display_name[entry_name] = dn
|
|
||||||
@entry_display_name_cache = info.entry_display_name
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def cover_url
|
|
||||||
cached_cover_url = @cached_cover_url
|
|
||||||
return cached_cover_url unless cached_cover_url.nil?
|
|
||||||
|
|
||||||
url = "#{Config.current.base_url}img/icon.png"
|
|
||||||
readable_entries = @entries.select &.err_msg.nil?
|
|
||||||
if readable_entries.size > 0
|
|
||||||
url = readable_entries[0].cover_url
|
|
||||||
end
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info_url = info.cover_url
|
|
||||||
unless info_url.nil? || info_url.empty?
|
|
||||||
url = File.join Config.current.base_url, info_url
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@cached_cover_url = url
|
|
||||||
url
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_cover_url(url : String)
|
|
||||||
@cached_cover_url = url
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info.cover_url = url
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_cover_url(entry_name : String, url : String)
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info.entry_cover_url[entry_name] = url
|
|
||||||
@entry_cover_url_cache = info.entry_cover_url
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Set the reading progress of all entries and nested libraries to 100%
|
|
||||||
def read_all(username)
|
|
||||||
@entries.each do |e|
|
|
||||||
e.save_progress username, e.pages
|
|
||||||
end
|
|
||||||
titles.each &.read_all username
|
|
||||||
end
|
|
||||||
|
|
||||||
# Set the reading progress of all entries and nested libraries to 0%
|
|
||||||
def unread_all(username)
|
|
||||||
@entries.each &.save_progress(username, 0)
|
|
||||||
titles.each &.unread_all username
|
|
||||||
end
|
|
||||||
|
|
||||||
def deep_read_page_count(username) : Int32
|
|
||||||
key = "#{@id}:#{username}:progress_sum"
|
|
||||||
sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
|
|
||||||
cached_sum = LRUCache.get key
|
|
||||||
return cached_sum[1] if cached_sum.is_a? Tuple(String, Int32) &&
|
|
||||||
cached_sum[0] == sig
|
|
||||||
sum = load_progress_for_all_entries(username, nil, true).sum +
|
|
||||||
titles.flat_map(&.deep_read_page_count username).sum
|
|
||||||
LRUCache.set generate_cache_entry key, {sig, sum}
|
|
||||||
sum
|
|
||||||
end
|
|
||||||
|
|
||||||
def deep_total_page_count : Int32
|
|
||||||
entries.sum(&.pages) +
|
|
||||||
titles.flat_map(&.deep_total_page_count).sum
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_percentage(username)
|
|
||||||
deep_read_page_count(username) / deep_total_page_count
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_progress_for_all_entries(username, opt : SortOptions? = nil,
|
|
||||||
unsorted = false)
|
|
||||||
progress = {} of String => Int32
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
progress = info.progress[username]?
|
|
||||||
end
|
|
||||||
|
|
||||||
if unsorted
|
|
||||||
ary = @entries
|
|
||||||
else
|
|
||||||
ary = sorted_entries username, opt
|
|
||||||
end
|
|
||||||
|
|
||||||
ary.map do |e|
|
|
||||||
info_progress = 0
|
|
||||||
if progress && progress.has_key? e.title
|
|
||||||
info_progress = [progress[e.title], e.pages].min
|
|
||||||
end
|
|
||||||
info_progress
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_percentage_for_all_entries(username, opt : SortOptions? = nil,
|
|
||||||
unsorted = false)
|
|
||||||
if unsorted
|
|
||||||
ary = @entries
|
|
||||||
else
|
|
||||||
ary = sorted_entries username, opt
|
|
||||||
end
|
|
||||||
|
|
||||||
progress = load_progress_for_all_entries username, opt, unsorted
|
|
||||||
ary.map_with_index do |e, i|
|
|
||||||
progress[i] / e.pages
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Returns the sorted entries array
|
|
||||||
#
|
|
||||||
# When `opt` is nil, it uses the preferred sorting options in info.json, or
|
|
||||||
# use the default (auto, ascending)
|
|
||||||
# When `opt` is not nil, it saves the options to info.json
|
|
||||||
def sorted_entries(username, opt : SortOptions? = nil)
|
|
||||||
cache_key = SortedEntriesCacheEntry.gen_key @id, username, @entries, opt
|
|
||||||
cached_entries = LRUCache.get cache_key
|
|
||||||
return cached_entries if cached_entries.is_a? Array(Entry)
|
|
||||||
|
|
||||||
if opt.nil?
|
|
||||||
opt = SortOptions.from_info_json @dir, username
|
|
||||||
end
|
|
||||||
|
|
||||||
case opt.not_nil!.method
|
|
||||||
when .title?
|
|
||||||
ary = @entries.sort { |a, b| compare_numerically a.title, b.title }
|
|
||||||
when .time_modified?
|
|
||||||
ary = @entries.sort { |a, b| (a.mtime <=> b.mtime).or \
|
|
||||||
compare_numerically a.title, b.title }
|
|
||||||
when .time_added?
|
|
||||||
ary = @entries.sort { |a, b| (a.date_added <=> b.date_added).or \
|
|
||||||
compare_numerically a.title, b.title }
|
|
||||||
when .progress?
|
|
||||||
percentage_ary = load_percentage_for_all_entries username, opt, true
|
|
||||||
ary = @entries.zip(percentage_ary)
|
|
||||||
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
|
|
||||||
compare_numerically a_tp[0].title, b_tp[0].title }
|
|
||||||
.map &.[0]
|
|
||||||
else
|
|
||||||
unless opt.method.auto?
|
|
||||||
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
|
||||||
"Auto instead"
|
|
||||||
end
|
|
||||||
sorter = ChapterSorter.new @entries.map &.title
|
|
||||||
ary = @entries.sort do |a, b|
|
|
||||||
sorter.compare(a.title, b.title).or \
|
|
||||||
compare_numerically a.title, b.title
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
ary.reverse! unless opt.not_nil!.ascend
|
|
||||||
|
|
||||||
LRUCache.set generate_cache_entry cache_key, ary
|
|
||||||
ary
|
|
||||||
end
|
|
||||||
|
|
||||||
# === helper methods ===
|
|
||||||
|
|
||||||
# Gets the last read entry in the title. If the entry has been completed,
|
|
||||||
# returns the next entry. Returns nil when no entry has been read yet,
|
|
||||||
# or when all entries are completed
|
|
||||||
def get_last_read_entry(username) : Entry?
|
|
||||||
progress = {} of String => Int32
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
progress = info.progress[username]?
|
|
||||||
end
|
|
||||||
return if progress.nil?
|
|
||||||
|
|
||||||
last_read_entry = nil
|
|
||||||
|
|
||||||
sorted_entries(username).reverse_each do |e|
|
|
||||||
if progress.has_key?(e.title) && progress[e.title] > 0
|
|
||||||
last_read_entry = e
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if last_read_entry && last_read_entry.finished? username
|
|
||||||
last_read_entry = last_read_entry.next_entry username
|
|
||||||
end
|
|
||||||
|
|
||||||
last_read_entry
|
|
||||||
end
|
|
||||||
|
|
||||||
# Equivalent to `@entries.map &. date_added`, but much more efficient
|
|
||||||
def get_date_added_for_all_entries
|
|
||||||
da = {} of String => Time
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
da = info.date_added
|
|
||||||
end
|
|
||||||
|
|
||||||
@entries.each do |e|
|
|
||||||
next if da.has_key? e.title
|
|
||||||
da[e.title] = ctime e.zip_path
|
|
||||||
end
|
|
||||||
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info.date_added = da
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
|
|
||||||
@entries.map { |e| da[e.title] }
|
|
||||||
end
|
|
||||||
|
|
||||||
def deep_entries_with_date_added
|
|
||||||
da_ary = get_date_added_for_all_entries
|
|
||||||
zip = @entries.map_with_index do |e, i|
|
|
||||||
{entry: e, date_added: da_ary[i]}
|
|
||||||
end
|
|
||||||
return zip if title_ids.empty?
|
|
||||||
zip + titles.flat_map &.deep_entries_with_date_added
|
|
||||||
end
|
|
||||||
|
|
||||||
def bulk_progress(action, ids : Array(String), username)
|
|
||||||
LRUCache.invalidate "#{@id}:#{username}:progress_sum"
|
|
||||||
parents.each do |parent|
|
|
||||||
LRUCache.invalidate "#{parent.id}:#{username}:progress_sum"
|
|
||||||
end
|
|
||||||
[false, true].each do |ascend|
|
|
||||||
sorted_entries_cache_key =
|
|
||||||
SortedEntriesCacheEntry.gen_key @id, username, @entries,
|
|
||||||
SortOptions.new(SortMethod::Progress, ascend)
|
|
||||||
LRUCache.invalidate sorted_entries_cache_key
|
|
||||||
end
|
|
||||||
|
|
||||||
selected_entries = ids
|
|
||||||
.map { |id|
|
|
||||||
@entries.find &.id.==(id)
|
|
||||||
}
|
|
||||||
.select(Entry)
|
|
||||||
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
selected_entries.each do |e|
|
|
||||||
page = action == "read" ? e.pages : 0
|
|
||||||
if info.progress[username]?.nil?
|
|
||||||
info.progress[username] = {e.title => page}
|
|
||||||
else
|
|
||||||
info.progress[username][e.title] = page
|
|
||||||
end
|
|
||||||
end
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
SUPPORTED_IMG_TYPES = %w(
|
|
||||||
image/jpeg
|
|
||||||
image/png
|
|
||||||
image/webp
|
|
||||||
image/apng
|
|
||||||
image/avif
|
|
||||||
image/gif
|
|
||||||
image/svg+xml
|
|
||||||
)
|
|
||||||
|
|
||||||
enum SortMethod
|
|
||||||
Auto
|
|
||||||
Title
|
|
||||||
Progress
|
|
||||||
TimeModified
|
|
||||||
TimeAdded
|
|
||||||
end
|
|
||||||
|
|
||||||
class SortOptions
|
|
||||||
property method : SortMethod, ascend : Bool
|
|
||||||
|
|
||||||
def initialize(in_method : String? = nil, @ascend = true)
|
|
||||||
@method = SortMethod::Auto
|
|
||||||
SortMethod.each do |m, _|
|
|
||||||
if in_method && m.to_s.underscore == in_method
|
|
||||||
@method = m
|
|
||||||
return
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def initialize(in_method : SortMethod? = nil, @ascend = true)
|
|
||||||
if in_method
|
|
||||||
@method = in_method
|
|
||||||
else
|
|
||||||
@method = SortMethod::Auto
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.from_tuple(tp : Tuple(String, Bool))
|
|
||||||
method, ascend = tp
|
|
||||||
self.new method, ascend
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.from_info_json(dir, username)
|
|
||||||
opt = SortOptions.new
|
|
||||||
TitleInfo.new dir do |info|
|
|
||||||
if info.sort_by.has_key? username
|
|
||||||
opt = SortOptions.from_tuple info.sort_by[username]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
opt
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_tuple
|
|
||||||
{@method.to_s.underscore, ascend}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
struct Image
|
|
||||||
property data : Bytes
|
|
||||||
property mime : String
|
|
||||||
property filename : String
|
|
||||||
property size : Int32
|
|
||||||
|
|
||||||
def initialize(@data, @mime, @filename, @size)
|
|
||||||
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
|
|
||||||
|
|
||||||
class TitleInfo
|
|
||||||
include JSON::Serializable
|
|
||||||
|
|
||||||
property comment = "Generated by Mango. DO NOT EDIT!"
|
|
||||||
property progress = {} of String => Hash(String, Int32)
|
|
||||||
property display_name = ""
|
|
||||||
property entry_display_name = {} of String => String
|
|
||||||
property cover_url = ""
|
|
||||||
property entry_cover_url = {} of String => String
|
|
||||||
property last_read = {} of String => Hash(String, Time)
|
|
||||||
property date_added = {} of String => Time
|
|
||||||
property sort_by = {} of String => Tuple(String, Bool)
|
|
||||||
|
|
||||||
@[JSON::Field(ignore: true)]
|
|
||||||
property dir : String = ""
|
|
||||||
|
|
||||||
@@mutex_hash = {} of String => Mutex
|
|
||||||
|
|
||||||
def self.new(dir, &)
|
|
||||||
key = "#{dir}:info.json"
|
|
||||||
info = LRUCache.get key
|
|
||||||
if info.is_a? String
|
|
||||||
begin
|
|
||||||
instance = TitleInfo.from_json info
|
|
||||||
instance.dir = dir
|
|
||||||
yield instance
|
|
||||||
return
|
|
||||||
rescue
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if @@mutex_hash[dir]?
|
|
||||||
mutex = @@mutex_hash[dir]
|
|
||||||
else
|
|
||||||
mutex = Mutex.new
|
|
||||||
@@mutex_hash[dir] = mutex
|
|
||||||
end
|
|
||||||
mutex.synchronize do
|
|
||||||
instance = TitleInfo.allocate
|
|
||||||
json_path = File.join dir, "info.json"
|
|
||||||
if File.exists? json_path
|
|
||||||
instance = TitleInfo.from_json File.read json_path
|
|
||||||
end
|
|
||||||
instance.dir = dir
|
|
||||||
LRUCache.set generate_cache_entry key, instance.to_json
|
|
||||||
yield instance
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def save
|
|
||||||
json_path = File.join @dir, "info.json"
|
|
||||||
File.write json_path, self.to_pretty_json
|
|
||||||
key = "#{@dir}:info.json"
|
|
||||||
LRUCache.set generate_cache_entry key, self.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
alias ExamineContext = NamedTuple(
|
|
||||||
cached_contents_signature: Hash(String, String),
|
|
||||||
deleted_title_ids: Array(String),
|
|
||||||
deleted_entry_ids: Array(String))
|
|
||||||
@@ -6,17 +6,26 @@ class Logger
|
|||||||
SEVERITY_IDS = [0, 4, 5, 2, 3]
|
SEVERITY_IDS = [0, 4, 5, 2, 3]
|
||||||
COLORS = [:light_cyan, :light_red, :red, :light_yellow, :light_magenta]
|
COLORS = [:light_cyan, :light_red, :red, :light_yellow, :light_magenta]
|
||||||
|
|
||||||
getter raw_log = Log.for ""
|
|
||||||
|
|
||||||
@@severity : Log::Severity = :info
|
@@severity : Log::Severity = :info
|
||||||
|
|
||||||
use_default
|
def initialize(level : String)
|
||||||
|
{% begin %}
|
||||||
|
case level.downcase
|
||||||
|
when "off"
|
||||||
|
@@severity = :none
|
||||||
|
{% for lvl, i in LEVELS %}
|
||||||
|
when {{lvl}}
|
||||||
|
@@severity = Log::Severity.new SEVERITY_IDS[{{i}}]
|
||||||
|
{% end %}
|
||||||
|
else
|
||||||
|
raise "Unknown log level #{level}"
|
||||||
|
end
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
@log = Log.for("")
|
||||||
|
|
||||||
def initialize
|
|
||||||
@@severity = Logger.get_severity
|
|
||||||
@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
|
||||||
@@ -33,48 +42,17 @@ class Logger
|
|||||||
io << entry.message
|
io << entry.message
|
||||||
end
|
end
|
||||||
|
|
||||||
@backend.formatter = Log::Formatter.new &format_proc
|
Log.builder.bind "*", @@severity, @backend
|
||||||
|
|
||||||
Log.setup do |c|
|
|
||||||
c.bind "*", @@severity, @backend
|
|
||||||
c.bind "db.*", :error, @backend
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.get_severity(level = "") : Log::Severity
|
|
||||||
if level.empty?
|
|
||||||
level = Config.current.log_level
|
|
||||||
end
|
|
||||||
{% begin %}
|
|
||||||
case level.downcase
|
|
||||||
when "off"
|
|
||||||
return Log::Severity::None
|
|
||||||
{% for lvl, i in LEVELS %}
|
|
||||||
when {{lvl}}
|
|
||||||
return Log::Severity.new SEVERITY_IDS[{{i}}]
|
|
||||||
{% end %}
|
|
||||||
else
|
|
||||||
raise "Unknown log level #{level}"
|
|
||||||
end
|
|
||||||
{% end %}
|
|
||||||
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,
|
@backend.write Log::Entry.new "", Log::Severity::None, msg, nil
|
||||||
Log::Metadata.empty, nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.log(msg)
|
|
||||||
default.log msg
|
|
||||||
end
|
end
|
||||||
|
|
||||||
{% for lvl in LEVELS %}
|
{% for lvl in LEVELS %}
|
||||||
def {{lvl.id}}(msg)
|
def {{lvl.id}}(msg)
|
||||||
raw_log.{{lvl.id}} { msg }
|
@log.{{lvl.id}} { msg }
|
||||||
end
|
|
||||||
def self.{{lvl.id}}(msg)
|
|
||||||
default.not_nil!.{{lvl.id}} msg
|
|
||||||
end
|
end
|
||||||
{% end %}
|
{% end %}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# On ARM, connecting to the SQLite DB from a spawned fiber would crash
|
|
||||||
# https://github.com/crystal-lang/crystal-sqlite3/issues/30
|
|
||||||
# This is a temporary workaround that forces the relevant code to run in the
|
|
||||||
# main fiber
|
|
||||||
|
|
||||||
class MainFiber
|
|
||||||
@@channel = Channel(-> Nil).new
|
|
||||||
@@done = Channel(Bool).new
|
|
||||||
@@main_fiber = Fiber.current
|
|
||||||
|
|
||||||
def self.start_and_block
|
|
||||||
loop do
|
|
||||||
if proc = @@channel.receive
|
|
||||||
begin
|
|
||||||
proc.call
|
|
||||||
ensure
|
|
||||||
@@done.send true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
Fiber.yield
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.run(&block : -> Nil)
|
|
||||||
if @@main_fiber == Fiber.current
|
|
||||||
block.call
|
|
||||||
else
|
|
||||||
@@channel.send block
|
|
||||||
until @@done.receive
|
|
||||||
Fiber.yield
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
197
src/mangadex/api.cr
Normal file
197
src/mangadex/api.cr
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
require "http/client"
|
||||||
|
require "json"
|
||||||
|
require "csv"
|
||||||
|
|
||||||
|
macro string_properties(names)
|
||||||
|
{% for name in names %}
|
||||||
|
property {{name.id}} = ""
|
||||||
|
{% end %}
|
||||||
|
end
|
||||||
|
|
||||||
|
macro parse_strings_from_json(names)
|
||||||
|
{% for name in names %}
|
||||||
|
@{{name.id}} = obj[{{name}}].as_s
|
||||||
|
{% end %}
|
||||||
|
end
|
||||||
|
|
||||||
|
module MangaDex
|
||||||
|
class Chapter
|
||||||
|
string_properties ["lang_code", "title", "volume", "chapter"]
|
||||||
|
property manga : Manga
|
||||||
|
property time = Time.local
|
||||||
|
property id : String
|
||||||
|
property full_title = ""
|
||||||
|
property language = ""
|
||||||
|
property pages = [] of {String, String} # filename, url
|
||||||
|
property groups = [] of {Int32, String} # group_id, group_name
|
||||||
|
|
||||||
|
def initialize(@id, json_obj : JSON::Any, @manga,
|
||||||
|
lang : Hash(String, String))
|
||||||
|
self.parse_json json_obj, lang
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_info_json
|
||||||
|
JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
{% for name in ["id", "title", "volume", "chapter",
|
||||||
|
"language", "full_title"] %}
|
||||||
|
json.field {{name}}, @{{name.id}}
|
||||||
|
{% end %}
|
||||||
|
json.field "time", @time.to_unix.to_s
|
||||||
|
json.field "manga_title", @manga.title
|
||||||
|
json.field "manga_id", @manga.id
|
||||||
|
json.field "groups" do
|
||||||
|
json.object do
|
||||||
|
@groups.each do |gid, gname|
|
||||||
|
json.field gname, gid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_json(obj, lang)
|
||||||
|
parse_strings_from_json ["lang_code", "title", "volume",
|
||||||
|
"chapter"]
|
||||||
|
language = lang[@lang_code]?
|
||||||
|
@language = language if language
|
||||||
|
@time = Time.unix obj["timestamp"].as_i
|
||||||
|
suffixes = ["", "_2", "_3"]
|
||||||
|
suffixes.each do |s|
|
||||||
|
gid = obj["group_id#{s}"].as_i
|
||||||
|
next if gid == 0
|
||||||
|
gname = obj["group_name#{s}"].as_s
|
||||||
|
@groups << {gid, gname}
|
||||||
|
end
|
||||||
|
@full_title = @title
|
||||||
|
unless @chapter.empty?
|
||||||
|
@full_title = "Ch.#{@chapter} " + @full_title
|
||||||
|
end
|
||||||
|
unless @volume.empty?
|
||||||
|
@full_title = "Vol.#{@volume} " + @full_title
|
||||||
|
end
|
||||||
|
rescue e
|
||||||
|
raise "failed to parse json: #{e}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Manga
|
||||||
|
string_properties ["cover_url", "description", "title", "author", "artist"]
|
||||||
|
property chapters = [] of Chapter
|
||||||
|
property id : String
|
||||||
|
|
||||||
|
def initialize(@id, json_obj : JSON::Any)
|
||||||
|
self.parse_json json_obj
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_info_json(with_chapters = true)
|
||||||
|
JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
{% for name in ["id", "title", "description", "author", "artist",
|
||||||
|
"cover_url"] %}
|
||||||
|
json.field {{name}}, @{{name.id}}
|
||||||
|
{% end %}
|
||||||
|
if with_chapters
|
||||||
|
json.field "chapters" do
|
||||||
|
json.array do
|
||||||
|
@chapters.each do |c|
|
||||||
|
json.raw c.to_info_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_json(obj)
|
||||||
|
parse_strings_from_json ["cover_url", "description", "title", "author",
|
||||||
|
"artist"]
|
||||||
|
rescue e
|
||||||
|
raise "failed to parse json: #{e}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class API
|
||||||
|
def initialize(@base_url = "https://mangadex.org/api/")
|
||||||
|
@lang = {} of String => String
|
||||||
|
CSV.each_row {{read_file "src/assets/lang_codes.csv"}} do |row|
|
||||||
|
@lang[row[1]] = row[0]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get(url)
|
||||||
|
headers = HTTP::Headers{
|
||||||
|
"User-agent" => "Mangadex.cr",
|
||||||
|
}
|
||||||
|
res = HTTP::Client.get url, headers
|
||||||
|
raise "Failed to get #{url}. [#{res.status_code}] " \
|
||||||
|
"#{res.status_message}" if !res.success?
|
||||||
|
JSON.parse res.body
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_manga(id)
|
||||||
|
obj = self.get File.join @base_url, "manga/#{id}"
|
||||||
|
if obj["status"]? != "OK"
|
||||||
|
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
||||||
|
end
|
||||||
|
begin
|
||||||
|
manga = Manga.new id, obj["manga"]
|
||||||
|
obj["chapter"].as_h.map do |k, v|
|
||||||
|
chapter = Chapter.new k, v, manga, @lang
|
||||||
|
manga.chapters << chapter
|
||||||
|
end
|
||||||
|
manga
|
||||||
|
rescue
|
||||||
|
raise "Failed to parse JSON"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_chapter(chapter : Chapter)
|
||||||
|
obj = self.get File.join @base_url, "chapter/#{chapter.id}"
|
||||||
|
if obj["status"]? == "external"
|
||||||
|
raise "This chapter is hosted on an external site " \
|
||||||
|
"#{obj["external"]?}, and Mango does not support " \
|
||||||
|
"external chapters."
|
||||||
|
end
|
||||||
|
if obj["status"]? != "OK"
|
||||||
|
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
||||||
|
end
|
||||||
|
begin
|
||||||
|
server = obj["server"].as_s
|
||||||
|
hash = obj["hash"].as_s
|
||||||
|
chapter.pages = obj["page_array"].as_a.map do |fn|
|
||||||
|
{
|
||||||
|
fn.as_s,
|
||||||
|
"#{server}#{hash}/#{fn.as_s}",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
raise "Failed to parse JSON"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_chapter(id : String)
|
||||||
|
obj = self.get File.join @base_url, "chapter/#{id}"
|
||||||
|
if obj["status"]? == "external"
|
||||||
|
raise "This chapter is hosted on an external site " \
|
||||||
|
"#{obj["external"]?}, and Mango does not support " \
|
||||||
|
"external chapters."
|
||||||
|
end
|
||||||
|
if obj["status"]? != "OK"
|
||||||
|
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
||||||
|
end
|
||||||
|
manga_id = ""
|
||||||
|
begin
|
||||||
|
manga_id = obj["manga_id"].as_i.to_s
|
||||||
|
rescue
|
||||||
|
raise "Failed to parse JSON"
|
||||||
|
end
|
||||||
|
manga = self.get_manga manga_id
|
||||||
|
chapter = manga.chapters.find { |c| c.id == id }.not_nil!
|
||||||
|
self.get_chapter chapter
|
||||||
|
chapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
385
src/mangadex/downloader.cr
Normal file
385
src/mangadex/downloader.cr
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
require "./api"
|
||||||
|
require "sqlite3"
|
||||||
|
|
||||||
|
module MangaDex
|
||||||
|
class PageJob
|
||||||
|
property success = false
|
||||||
|
property url : String
|
||||||
|
property filename : String
|
||||||
|
property writer : Zip::Writer
|
||||||
|
property tries_remaning : Int32
|
||||||
|
|
||||||
|
def initialize(@url, @filename, @writer, @tries_remaning)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
enum JobStatus
|
||||||
|
Pending # 0
|
||||||
|
Downloading # 1
|
||||||
|
Error # 2
|
||||||
|
Completed # 3
|
||||||
|
MissingPages # 4
|
||||||
|
end
|
||||||
|
|
||||||
|
struct Job
|
||||||
|
property id : String
|
||||||
|
property manga_id : String
|
||||||
|
property title : String
|
||||||
|
property manga_title : String
|
||||||
|
property status : JobStatus
|
||||||
|
property status_message : String = ""
|
||||||
|
property pages : Int32 = 0
|
||||||
|
property success_count : Int32 = 0
|
||||||
|
property fail_count : Int32 = 0
|
||||||
|
property time : Time
|
||||||
|
|
||||||
|
def parse_query_result(res : DB::ResultSet)
|
||||||
|
@id = res.read String
|
||||||
|
@manga_id = res.read String
|
||||||
|
@title = res.read String
|
||||||
|
@manga_title = res.read String
|
||||||
|
status = res.read Int32
|
||||||
|
@status_message = res.read String
|
||||||
|
@pages = res.read Int32
|
||||||
|
@success_count = res.read Int32
|
||||||
|
@fail_count = res.read Int32
|
||||||
|
time = res.read Int64
|
||||||
|
@status = JobStatus.new status
|
||||||
|
@time = Time.unix_ms time
|
||||||
|
end
|
||||||
|
|
||||||
|
# Raises if the result set does not contain the correct set of columns
|
||||||
|
def self.from_query_result(res : DB::ResultSet)
|
||||||
|
job = Job.allocate
|
||||||
|
job.parse_query_result res
|
||||||
|
job
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(@id, @manga_id, @title, @manga_title, @status, @time)
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(json)
|
||||||
|
json.object do
|
||||||
|
{% for name in ["id", "manga_id", "title", "manga_title",
|
||||||
|
"status_message"] %}
|
||||||
|
json.field {{name}}, @{{name.id}}
|
||||||
|
{% end %}
|
||||||
|
{% for name in ["pages", "success_count", "fail_count"] %}
|
||||||
|
json.field {{name}} do
|
||||||
|
json.number @{{name.id}}
|
||||||
|
end
|
||||||
|
{% end %}
|
||||||
|
json.field "status", @status.to_s
|
||||||
|
json.field "time" do
|
||||||
|
json.number @time.to_unix_ms
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Queue
|
||||||
|
property downloader : Downloader?
|
||||||
|
|
||||||
|
def initialize(@path : String, @logger : Logger)
|
||||||
|
dir = File.dirname path
|
||||||
|
unless Dir.exists? dir
|
||||||
|
@logger.info "The queue DB directory #{dir} does not exist. " \
|
||||||
|
"Attepmting to create it"
|
||||||
|
Dir.mkdir_p dir
|
||||||
|
end
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
begin
|
||||||
|
db.exec "create table if not exists queue " \
|
||||||
|
"(id text, manga_id text, title text, manga_title " \
|
||||||
|
"text, status integer, status_message text, " \
|
||||||
|
"pages integer, success_count integer, " \
|
||||||
|
"fail_count integer, time integer)"
|
||||||
|
db.exec "create unique index if not exists id_idx " \
|
||||||
|
"on queue (id)"
|
||||||
|
db.exec "create index if not exists manga_id_idx " \
|
||||||
|
"on queue (manga_id)"
|
||||||
|
db.exec "create index if not exists status_idx " \
|
||||||
|
"on queue (status)"
|
||||||
|
rescue e
|
||||||
|
@logger.error "Error when checking tables in DB: #{e}"
|
||||||
|
raise e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns the earliest job in queue or nil if the job cannot be parsed.
|
||||||
|
# Returns nil if queue is empty
|
||||||
|
def pop
|
||||||
|
job = nil
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
begin
|
||||||
|
db.query_one "select * from queue where status = 0 " \
|
||||||
|
"or status = 1 order by time limit 1" do |res|
|
||||||
|
job = Job.from_query_result res
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
end
|
||||||
|
end
|
||||||
|
job
|
||||||
|
end
|
||||||
|
|
||||||
|
# Push an array of jobs into the queue, and return the number of jobs
|
||||||
|
# inserted. Any job already exists in the queue will be ignored.
|
||||||
|
def push(jobs : Array(Job))
|
||||||
|
start_count = self.count
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
jobs.each do |job|
|
||||||
|
db.exec "insert or ignore into queue values " \
|
||||||
|
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
job.id, job.manga_id, job.title, job.manga_title,
|
||||||
|
job.status.to_i, job.status_message, job.pages,
|
||||||
|
job.success_count, job.fail_count, job.time.to_unix_ms
|
||||||
|
end
|
||||||
|
end
|
||||||
|
self.count - start_count
|
||||||
|
end
|
||||||
|
|
||||||
|
def reset(id : String)
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
db.exec "update queue set status = 0, status_message = '', " \
|
||||||
|
"pages = 0, success_count = 0, fail_count = 0 " \
|
||||||
|
"where id = (?)", id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def reset(job : Job)
|
||||||
|
self.reset job.id
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reset all failed tasks (missing pages and error)
|
||||||
|
def reset
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
db.exec "update queue set status = 0, status_message = '', " \
|
||||||
|
"pages = 0, success_count = 0, fail_count = 0 " \
|
||||||
|
"where status = 2 or status = 4"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete(id : String)
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
db.exec "delete from queue where id = (?)", id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete(job : Job)
|
||||||
|
self.delete job.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_status(status : JobStatus)
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
db.exec "delete from queue where status = (?)", status.to_i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def count_status(status : JobStatus)
|
||||||
|
num = 0
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
num = db.query_one "select count(*) from queue where " \
|
||||||
|
"status = (?)", status.to_i, as: Int32
|
||||||
|
end
|
||||||
|
num
|
||||||
|
end
|
||||||
|
|
||||||
|
def count
|
||||||
|
num = 0
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
num = db.query_one "select count(*) from queue", as: Int32
|
||||||
|
end
|
||||||
|
num
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_status(status : JobStatus, job : Job)
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
db.exec "update queue set status = (?) where id = (?)",
|
||||||
|
status.to_i, job.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_all
|
||||||
|
jobs = [] of Job
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
jobs = db.query_all "select * from queue order by time" do |rs|
|
||||||
|
Job.from_query_result rs
|
||||||
|
end
|
||||||
|
end
|
||||||
|
jobs
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_success(job : Job)
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
db.exec "update queue set success_count = success_count + 1 " \
|
||||||
|
"where id = (?)", job.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_fail(job : Job)
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
db.exec "update queue set fail_count = fail_count + 1 " \
|
||||||
|
"where id = (?)", job.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_pages(pages : Int32, job : Job)
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
db.exec "update queue set pages = (?), success_count = 0, " \
|
||||||
|
"fail_count = 0 where id = (?)", pages, job.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_message(msg : String, job : Job)
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
db.exec "update queue set status_message = " \
|
||||||
|
"status_message || (?) || (?) where id = (?)",
|
||||||
|
"\n", msg, job.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def pause
|
||||||
|
@downloader.not_nil!.stopped = true
|
||||||
|
end
|
||||||
|
|
||||||
|
def resume
|
||||||
|
@downloader.not_nil!.stopped = false
|
||||||
|
end
|
||||||
|
|
||||||
|
def paused?
|
||||||
|
@downloader.not_nil!.stopped
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Downloader
|
||||||
|
property stopped = false
|
||||||
|
@downloading = false
|
||||||
|
|
||||||
|
def initialize(@queue : Queue, @api : API, @library_path : String,
|
||||||
|
@wait_seconds : Int32, @retries : Int32,
|
||||||
|
@logger : Logger)
|
||||||
|
@queue.downloader = self
|
||||||
|
|
||||||
|
spawn do
|
||||||
|
loop do
|
||||||
|
sleep 1.second
|
||||||
|
next if @stopped || @downloading
|
||||||
|
begin
|
||||||
|
job = @queue.pop
|
||||||
|
next if job.nil?
|
||||||
|
download job
|
||||||
|
rescue e
|
||||||
|
@logger.error e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def download(job : Job)
|
||||||
|
@downloading = true
|
||||||
|
@queue.set_status JobStatus::Downloading, job
|
||||||
|
begin
|
||||||
|
chapter = @api.get_chapter(job.id)
|
||||||
|
rescue e
|
||||||
|
@logger.error e
|
||||||
|
@queue.set_status JobStatus::Error, job
|
||||||
|
unless e.message.nil?
|
||||||
|
@queue.add_message e.message.not_nil!, job
|
||||||
|
end
|
||||||
|
@downloading = false
|
||||||
|
return
|
||||||
|
end
|
||||||
|
@queue.set_pages chapter.pages.size, job
|
||||||
|
lib_dir = @library_path
|
||||||
|
manga_dir = File.join lib_dir, chapter.manga.title
|
||||||
|
unless File.exists? manga_dir
|
||||||
|
Dir.mkdir_p manga_dir
|
||||||
|
end
|
||||||
|
zip_path = File.join manga_dir, "#{job.title}.cbz"
|
||||||
|
|
||||||
|
# Find the number of digits needed to store the number of pages
|
||||||
|
len = Math.log10(chapter.pages.size).to_i + 1
|
||||||
|
|
||||||
|
writer = Zip::Writer.new zip_path
|
||||||
|
# Create a buffered channel. It works as an FIFO queue
|
||||||
|
channel = Channel(PageJob).new chapter.pages.size
|
||||||
|
spawn do
|
||||||
|
chapter.pages.each_with_index do |tuple, i|
|
||||||
|
fn, url = tuple
|
||||||
|
ext = File.extname fn
|
||||||
|
fn = "#{i.to_s.rjust len, '0'}#{ext}"
|
||||||
|
page_job = PageJob.new url, fn, writer, @retries
|
||||||
|
@logger.debug "Downloading #{url}"
|
||||||
|
loop do
|
||||||
|
sleep @wait_seconds.seconds
|
||||||
|
download_page page_job
|
||||||
|
break if page_job.success ||
|
||||||
|
page_job.tries_remaning <= 0
|
||||||
|
page_job.tries_remaning -= 1
|
||||||
|
@logger.warn "Failed to download page #{url}. " \
|
||||||
|
"Retrying... Remaining retries: " \
|
||||||
|
"#{page_job.tries_remaning}"
|
||||||
|
end
|
||||||
|
|
||||||
|
channel.send page_job
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
spawn do
|
||||||
|
page_jobs = [] of PageJob
|
||||||
|
chapter.pages.size.times do
|
||||||
|
page_job = channel.receive
|
||||||
|
@logger.debug "[#{page_job.success ? "success" : "failed"}] " \
|
||||||
|
"#{page_job.url}"
|
||||||
|
page_jobs << page_job
|
||||||
|
if page_job.success
|
||||||
|
@queue.add_success job
|
||||||
|
else
|
||||||
|
@queue.add_fail job
|
||||||
|
msg = "Failed to download page #{page_job.url}"
|
||||||
|
@queue.add_message msg, job
|
||||||
|
@logger.error msg
|
||||||
|
end
|
||||||
|
end
|
||||||
|
fail_count = page_jobs.count { |j| !j.success }
|
||||||
|
@logger.debug "Download completed. " \
|
||||||
|
"#{fail_count}/#{page_jobs.size} failed"
|
||||||
|
writer.close
|
||||||
|
@logger.debug "cbz File created at #{zip_path}"
|
||||||
|
|
||||||
|
zip_exception = validate_zip zip_path
|
||||||
|
if !zip_exception.nil?
|
||||||
|
@queue.add_message "The downloaded archive is corrupted. " \
|
||||||
|
"Error: #{zip_exception}", job
|
||||||
|
@queue.set_status JobStatus::Error, job
|
||||||
|
elsif fail_count > 0
|
||||||
|
@queue.set_status JobStatus::MissingPages, job
|
||||||
|
else
|
||||||
|
@queue.set_status JobStatus::Completed, job
|
||||||
|
end
|
||||||
|
@downloading = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def download_page(job : PageJob)
|
||||||
|
@logger.debug "downloading #{job.url}"
|
||||||
|
headers = HTTP::Headers{
|
||||||
|
"User-agent" => "Mangadex.cr",
|
||||||
|
}
|
||||||
|
begin
|
||||||
|
HTTP::Client.get job.url, headers do |res|
|
||||||
|
unless res.success?
|
||||||
|
raise "Failed to download page #{job.url}. " \
|
||||||
|
"[#{res.status_code}] #{res.status_message}"
|
||||||
|
end
|
||||||
|
job.writer.add job.filename, res.body_io
|
||||||
|
end
|
||||||
|
job.success = true
|
||||||
|
rescue e
|
||||||
|
@logger.error e
|
||||||
|
job.success = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
152
src/mango.cr
152
src/mango.cr
@@ -1,137 +1,41 @@
|
|||||||
require "./config"
|
|
||||||
require "./queue"
|
|
||||||
require "./server"
|
require "./server"
|
||||||
require "./main_fiber"
|
require "./context"
|
||||||
require "./plugin/*"
|
require "./mangadex/*"
|
||||||
require "option_parser"
|
require "option_parser"
|
||||||
require "clim"
|
|
||||||
require "tallboy"
|
|
||||||
|
|
||||||
MANGO_VERSION = "0.24.0"
|
VERSION = "0.3.0"
|
||||||
|
|
||||||
# From http://www.network-science.de/ascii/
|
config_path = nil
|
||||||
BANNER = %{
|
|
||||||
|
|
||||||
_| _|
|
OptionParser.parse do |parser|
|
||||||
_|_| _|_| _|_|_| _|_|_| _|_|_| _|_|
|
parser.banner = "Mango e-manga server/reader. Version #{VERSION}\n"
|
||||||
_| _| _| _| _| _| _| _| _| _| _|
|
|
||||||
_| _| _| _| _| _| _| _| _| _|
|
|
||||||
_| _| _|_|_| _| _| _|_|_| _|_|
|
|
||||||
_|
|
|
||||||
_|_|
|
|
||||||
|
|
||||||
|
parser.on "-v", "--version", "Show version" do
|
||||||
}
|
puts "Version #{VERSION}"
|
||||||
|
exit
|
||||||
DESCRIPTION = "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}"
|
|
||||||
|
|
||||||
macro common_option
|
|
||||||
option "-c PATH", "--config=PATH", type: String,
|
|
||||||
desc: "Path to the config file"
|
|
||||||
end
|
end
|
||||||
|
parser.on "-h", "--help", "Show help" do
|
||||||
macro throw(msg)
|
puts parser
|
||||||
puts "ERROR: #{{{msg}}}"
|
exit
|
||||||
puts
|
|
||||||
puts "Please see the `--help`."
|
|
||||||
exit 1
|
|
||||||
end
|
end
|
||||||
|
parser.on "-c PATH", "--config=PATH",
|
||||||
class CLI < Clim
|
"Path to the config file. Default is `~/.config/mango/config.yml`" do |path|
|
||||||
main do
|
config_path = path
|
||||||
desc DESCRIPTION
|
|
||||||
usage "mango [sub_command] [options]"
|
|
||||||
help short: "-h"
|
|
||||||
version "Version #{MANGO_VERSION}", short: "-v"
|
|
||||||
common_option
|
|
||||||
run do |opts|
|
|
||||||
puts BANNER
|
|
||||||
puts DESCRIPTION
|
|
||||||
puts
|
|
||||||
|
|
||||||
# empty ARGV so it won't be passed to Kemal
|
|
||||||
ARGV.clear
|
|
||||||
|
|
||||||
Config.load(opts.config).set_current
|
|
||||||
|
|
||||||
# Initialize main components
|
|
||||||
LRUCache.init
|
|
||||||
Storage.default
|
|
||||||
Queue.default
|
|
||||||
Library.load_instance
|
|
||||||
Library.default
|
|
||||||
Plugin::Downloader.default
|
|
||||||
|
|
||||||
spawn do
|
|
||||||
begin
|
|
||||||
Server.new.start
|
|
||||||
rescue e
|
|
||||||
Logger.fatal e
|
|
||||||
Process.exit 1
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
MainFiber.start_and_block
|
config = Config.load config_path
|
||||||
end
|
logger = Logger.new config.log_level
|
||||||
|
storage = Storage.new config.db_path, logger
|
||||||
|
library = Library.new config.library_path, config.scan_interval, logger, storage
|
||||||
|
queue = MangaDex::Queue.new config.mangadex["download_queue_db_path"].to_s,
|
||||||
|
logger
|
||||||
|
api = MangaDex::API.new config.mangadex["api_url"].to_s
|
||||||
|
MangaDex::Downloader.new queue, api, config.library_path,
|
||||||
|
config.mangadex["download_wait_seconds"].to_i,
|
||||||
|
config.mangadex["download_retries"].to_i, logger
|
||||||
|
|
||||||
sub "admin" do
|
context = Context.new config, logger, library, storage, queue
|
||||||
desc "Run admin tools"
|
|
||||||
usage "mango admin [tool]"
|
|
||||||
help short: "-h"
|
|
||||||
run do |opts|
|
|
||||||
puts opts.help_string
|
|
||||||
end
|
|
||||||
sub "user" do
|
|
||||||
desc "User management tool"
|
|
||||||
usage "mango admin user [arguments] [options]"
|
|
||||||
help short: "-h"
|
|
||||||
argument "action", type: String,
|
|
||||||
desc: "Action to perform. Can be add/delete/update/list"
|
|
||||||
argument "username", type: String,
|
|
||||||
desc: "Username to update or delete"
|
|
||||||
option "-u USERNAME", "--username=USERNAME", type: String,
|
|
||||||
desc: "Username"
|
|
||||||
option "-p PASSWORD", "--password=PASSWORD", type: String,
|
|
||||||
desc: "Password"
|
|
||||||
option "-a", "--admin", desc: "Admin flag", type: Bool, default: false
|
|
||||||
common_option
|
|
||||||
run do |opts, args|
|
|
||||||
Config.load(opts.config).set_current
|
|
||||||
storage = Storage.new nil, false
|
|
||||||
|
|
||||||
case args.action
|
server = Server.new context
|
||||||
when "add"
|
server.start
|
||||||
throw "Options `-u` and `-p` required." if opts.username.nil? ||
|
|
||||||
opts.password.nil?
|
|
||||||
storage.new_user opts.username.not_nil!,
|
|
||||||
opts.password.not_nil!, opts.admin
|
|
||||||
when "delete"
|
|
||||||
throw "Argument `username` required." if args.username.nil?
|
|
||||||
storage.delete_user args.username
|
|
||||||
when "update"
|
|
||||||
throw "Argument `username` required." if args.username.nil?
|
|
||||||
username = opts.username || args.username
|
|
||||||
password = opts.password || ""
|
|
||||||
storage.update_user args.username, username.not_nil!,
|
|
||||||
password.not_nil!, opts.admin
|
|
||||||
when "list"
|
|
||||||
users = storage.list_users
|
|
||||||
table = Tallboy.table do
|
|
||||||
header ["username", "admin access"]
|
|
||||||
users.each do |name, admin|
|
|
||||||
row [name, admin]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
puts table
|
|
||||||
when nil
|
|
||||||
puts opts.help_string
|
|
||||||
else
|
|
||||||
throw "Unknown action \"#{args.action}\"."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
CLI.start(ARGV)
|
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
class Plugin
|
|
||||||
class Downloader < Queue::Downloader
|
|
||||||
use_default
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
super
|
|
||||||
end
|
|
||||||
|
|
||||||
def pop : Queue::Job?
|
|
||||||
job = nil
|
|
||||||
MainFiber.run do
|
|
||||||
DB.open "sqlite3://#{@queue.path}" do |db|
|
|
||||||
begin
|
|
||||||
db.query_one "select * from queue where id like '%-%' " \
|
|
||||||
"and (status = 0 or status = 1) " \
|
|
||||||
"order by time limit 1" do |res|
|
|
||||||
job = Queue::Job.from_query_result res
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
job
|
|
||||||
end
|
|
||||||
|
|
||||||
private def download(job : Queue::Job)
|
|
||||||
@downloading = true
|
|
||||||
@queue.set_status Queue::JobStatus::Downloading, job
|
|
||||||
|
|
||||||
begin
|
|
||||||
unless job.plugin_id
|
|
||||||
raise "Job does not have a plugin ID specificed"
|
|
||||||
end
|
|
||||||
|
|
||||||
plugin = Plugin.new job.plugin_id.not_nil!
|
|
||||||
info = plugin.select_chapter job.plugin_chapter_id.not_nil!
|
|
||||||
|
|
||||||
pages = info["pages"].as_i
|
|
||||||
|
|
||||||
manga_title = sanitize_filename job.manga_title
|
|
||||||
chapter_title = sanitize_filename info["title"].as_s
|
|
||||||
|
|
||||||
@queue.set_pages pages, job
|
|
||||||
lib_dir = @library_path
|
|
||||||
manga_dir = File.join lib_dir, manga_title
|
|
||||||
unless File.exists? manga_dir
|
|
||||||
Dir.mkdir_p manga_dir
|
|
||||||
end
|
|
||||||
|
|
||||||
zip_path = File.join manga_dir, "#{chapter_title}.cbz.part"
|
|
||||||
writer = Compress::Zip::Writer.new zip_path
|
|
||||||
rescue e
|
|
||||||
@queue.set_status Queue::JobStatus::Error, job
|
|
||||||
unless e.message.nil?
|
|
||||||
@queue.add_message e.message.not_nil!, job
|
|
||||||
end
|
|
||||||
@downloading = false
|
|
||||||
raise e
|
|
||||||
end
|
|
||||||
|
|
||||||
fail_count = 0
|
|
||||||
|
|
||||||
while page = plugin.next_page
|
|
||||||
break unless @queue.exists? job
|
|
||||||
|
|
||||||
fn = sanitize_filename page["filename"].as_s
|
|
||||||
url = page["url"].as_s
|
|
||||||
headers = HTTP::Headers.new
|
|
||||||
|
|
||||||
if page["headers"]?
|
|
||||||
page["headers"].as_h.each do |k, v|
|
|
||||||
headers.add k, v.as_s
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
page_success = false
|
|
||||||
tries = 4
|
|
||||||
|
|
||||||
loop do
|
|
||||||
sleep plugin.info.wait_seconds.seconds
|
|
||||||
Logger.debug "downloading #{url}"
|
|
||||||
tries -= 1
|
|
||||||
|
|
||||||
begin
|
|
||||||
HTTP::Client.get url, headers do |res|
|
|
||||||
unless res.success?
|
|
||||||
raise "Failed to download page #{url}. " \
|
|
||||||
"[#{res.status_code}] #{res.status_message}"
|
|
||||||
end
|
|
||||||
writer.add fn, res.body_io
|
|
||||||
end
|
|
||||||
rescue e
|
|
||||||
@queue.add_fail job
|
|
||||||
fail_count += 1
|
|
||||||
msg = "Failed to download page #{url}. Error: #{e}"
|
|
||||||
@queue.add_message msg, job
|
|
||||||
Logger.error msg
|
|
||||||
Logger.debug "[failed] #{url}"
|
|
||||||
else
|
|
||||||
@queue.add_success job
|
|
||||||
Logger.debug "[success] #{url}"
|
|
||||||
page_success = true
|
|
||||||
end
|
|
||||||
|
|
||||||
break if page_success || tries < 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
unless @queue.exists? job
|
|
||||||
Logger.debug "Download cancelled"
|
|
||||||
@downloading = false
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
Logger.debug "Download completed. #{fail_count}/#{pages} failed"
|
|
||||||
writer.close
|
|
||||||
filename = File.join File.dirname(zip_path), File.basename(zip_path,
|
|
||||||
".part")
|
|
||||||
File.rename zip_path, filename
|
|
||||||
Logger.debug "cbz File created at #{filename}"
|
|
||||||
|
|
||||||
zip_exception = validate_archive filename
|
|
||||||
if !zip_exception.nil?
|
|
||||||
@queue.add_message "The downloaded archive is corrupted. " \
|
|
||||||
"Error: #{zip_exception}", job
|
|
||||||
@queue.set_status Queue::JobStatus::Error, job
|
|
||||||
elsif fail_count > 0
|
|
||||||
@queue.set_status Queue::JobStatus::MissingPages, job
|
|
||||||
else
|
|
||||||
@queue.set_status Queue::JobStatus::Completed, job
|
|
||||||
end
|
|
||||||
|
|
||||||
@downloading = false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
require "duktape/runtime"
|
|
||||||
require "myhtml"
|
|
||||||
require "xml"
|
|
||||||
|
|
||||||
class Plugin
|
|
||||||
class Error < ::Exception
|
|
||||||
end
|
|
||||||
|
|
||||||
class MetadataError < Error
|
|
||||||
end
|
|
||||||
|
|
||||||
class PluginException < Error
|
|
||||||
end
|
|
||||||
|
|
||||||
class SyntaxError < Error
|
|
||||||
end
|
|
||||||
|
|
||||||
struct Info
|
|
||||||
{% for name in ["id", "title", "placeholder"] %}
|
|
||||||
getter {{name.id}} = ""
|
|
||||||
{% end %}
|
|
||||||
getter wait_seconds : UInt64 = 0
|
|
||||||
getter dir : String
|
|
||||||
|
|
||||||
def initialize(@dir)
|
|
||||||
info_path = File.join @dir, "info.json"
|
|
||||||
|
|
||||||
unless File.exists? info_path
|
|
||||||
raise MetadataError.new "File `info.json` not found in the " \
|
|
||||||
"plugin directory #{dir}"
|
|
||||||
end
|
|
||||||
|
|
||||||
@json = JSON.parse File.read info_path
|
|
||||||
|
|
||||||
begin
|
|
||||||
{% for name in ["id", "title", "placeholder"] %}
|
|
||||||
@{{name.id}} = @json[{{name}}].as_s
|
|
||||||
{% end %}
|
|
||||||
@wait_seconds = @json["wait_seconds"].as_i.to_u64
|
|
||||||
|
|
||||||
unless @id.alphanumeric_underscore?
|
|
||||||
raise "Plugin ID can only contain alphanumeric characters and " \
|
|
||||||
"underscores"
|
|
||||||
end
|
|
||||||
rescue e
|
|
||||||
raise MetadataError.new "Failed to retrieve metadata from plugin " \
|
|
||||||
"at #{@dir}. Error: #{e.message}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def each(&block : String, JSON::Any -> _)
|
|
||||||
@json.as_h.each &block
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
struct Storage
|
|
||||||
@hash = {} of String => String
|
|
||||||
|
|
||||||
def initialize(@path : String)
|
|
||||||
unless File.exists? @path
|
|
||||||
save
|
|
||||||
end
|
|
||||||
|
|
||||||
json = JSON.parse File.read @path
|
|
||||||
json.as_h.each do |k, v|
|
|
||||||
@hash[k] = v.as_s
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def []?(key)
|
|
||||||
@hash[key]?
|
|
||||||
end
|
|
||||||
|
|
||||||
def []=(key, val : String)
|
|
||||||
@hash[key] = val
|
|
||||||
end
|
|
||||||
|
|
||||||
def save
|
|
||||||
File.write @path, @hash.to_pretty_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@@info_ary = [] of Info
|
|
||||||
@info : Info?
|
|
||||||
|
|
||||||
getter js_path = ""
|
|
||||||
getter storage_path = ""
|
|
||||||
|
|
||||||
def self.build_info_ary
|
|
||||||
@@info_ary.clear
|
|
||||||
dir = Config.current.plugin_path
|
|
||||||
Dir.mkdir_p dir unless Dir.exists? dir
|
|
||||||
|
|
||||||
Dir.each_child dir do |f|
|
|
||||||
path = File.join dir, f
|
|
||||||
next unless File.directory? path
|
|
||||||
|
|
||||||
begin
|
|
||||||
@@info_ary << Info.new path
|
|
||||||
rescue e : MetadataError
|
|
||||||
Logger.warn e
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.list
|
|
||||||
self.build_info_ary
|
|
||||||
@@info_ary.map do |m|
|
|
||||||
{id: m.id, title: m.title}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def info
|
|
||||||
@info.not_nil!
|
|
||||||
end
|
|
||||||
|
|
||||||
def initialize(id : String)
|
|
||||||
Plugin.build_info_ary
|
|
||||||
|
|
||||||
@info = @@info_ary.find &.id.== id
|
|
||||||
if @info.nil?
|
|
||||||
raise Error.new "Plugin with ID #{id} not found"
|
|
||||||
end
|
|
||||||
|
|
||||||
@js_path = File.join info.dir, "index.js"
|
|
||||||
@storage_path = File.join info.dir, "storage.json"
|
|
||||||
|
|
||||||
unless File.exists? @js_path
|
|
||||||
raise Error.new "Plugin script not found at #{@js_path}"
|
|
||||||
end
|
|
||||||
|
|
||||||
@rt = Duktape::Runtime.new do |sbx|
|
|
||||||
sbx.push_global_object
|
|
||||||
|
|
||||||
sbx.push_pointer @storage_path.as(Void*)
|
|
||||||
path = sbx.require_pointer(-1).as String
|
|
||||||
sbx.pop
|
|
||||||
sbx.push_string path
|
|
||||||
sbx.put_prop_string -2, "storage_path"
|
|
||||||
|
|
||||||
def_helper_functions sbx
|
|
||||||
end
|
|
||||||
|
|
||||||
eval File.read @js_path
|
|
||||||
end
|
|
||||||
|
|
||||||
macro check_fields(ary)
|
|
||||||
{% for field in ary %}
|
|
||||||
unless json[{{field}}]?
|
|
||||||
raise "Field `{{field.id}}` is missing from the function outputs"
|
|
||||||
end
|
|
||||||
{% end %}
|
|
||||||
end
|
|
||||||
|
|
||||||
def list_chapters(query : String)
|
|
||||||
json = eval_json "listChapters('#{query}')"
|
|
||||||
begin
|
|
||||||
check_fields ["title", "chapters"]
|
|
||||||
|
|
||||||
ary = json["chapters"].as_a
|
|
||||||
ary.each do |obj|
|
|
||||||
id = obj["id"]?
|
|
||||||
raise "Field `id` missing from `listChapters` outputs" if id.nil?
|
|
||||||
|
|
||||||
unless id.to_s.alphanumeric_underscore?
|
|
||||||
raise "The `id` field can only contain alphanumeric characters " \
|
|
||||||
"and underscores"
|
|
||||||
end
|
|
||||||
|
|
||||||
title = obj["title"]?
|
|
||||||
raise "Field `title` missing from `listChapters` outputs" if title.nil?
|
|
||||||
end
|
|
||||||
rescue e
|
|
||||||
raise Error.new e.message
|
|
||||||
end
|
|
||||||
json
|
|
||||||
end
|
|
||||||
|
|
||||||
def select_chapter(id : String)
|
|
||||||
json = eval_json "selectChapter('#{id}')"
|
|
||||||
begin
|
|
||||||
check_fields ["title", "pages"]
|
|
||||||
|
|
||||||
if json["title"].to_s.empty?
|
|
||||||
raise "The `title` field of the chapter can not be empty"
|
|
||||||
end
|
|
||||||
rescue e
|
|
||||||
raise Error.new e.message
|
|
||||||
end
|
|
||||||
json
|
|
||||||
end
|
|
||||||
|
|
||||||
def next_page
|
|
||||||
json = eval_json "nextPage()"
|
|
||||||
return if json.size == 0
|
|
||||||
begin
|
|
||||||
check_fields ["filename", "url"]
|
|
||||||
rescue e
|
|
||||||
raise Error.new e.message
|
|
||||||
end
|
|
||||||
json
|
|
||||||
end
|
|
||||||
|
|
||||||
private def eval(str)
|
|
||||||
@rt.eval str
|
|
||||||
rescue e : Duktape::SyntaxError
|
|
||||||
raise SyntaxError.new e.message
|
|
||||||
rescue e : Duktape::Error
|
|
||||||
raise Error.new e.message
|
|
||||||
end
|
|
||||||
|
|
||||||
private def eval_json(str)
|
|
||||||
JSON.parse eval(str).as String
|
|
||||||
end
|
|
||||||
|
|
||||||
private def def_helper_functions(sbx)
|
|
||||||
sbx.push_object
|
|
||||||
|
|
||||||
sbx.push_proc LibDUK::VARARGS do |ptr|
|
|
||||||
env = Duktape::Sandbox.new ptr
|
|
||||||
url = env.require_string 0
|
|
||||||
|
|
||||||
headers = HTTP::Headers.new
|
|
||||||
|
|
||||||
if env.get_top == 2
|
|
||||||
env.enum 1, LibDUK::Enum::OwnPropertiesOnly
|
|
||||||
while env.next -1, true
|
|
||||||
key = env.require_string -2
|
|
||||||
val = env.require_string -1
|
|
||||||
headers.add key, val
|
|
||||||
env.pop_2
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
res = HTTP::Client.get url, headers
|
|
||||||
|
|
||||||
env.push_object
|
|
||||||
|
|
||||||
env.push_int res.status_code
|
|
||||||
env.put_prop_string -2, "status_code"
|
|
||||||
|
|
||||||
env.push_string res.body
|
|
||||||
env.put_prop_string -2, "body"
|
|
||||||
|
|
||||||
env.push_object
|
|
||||||
res.headers.each do |k, v|
|
|
||||||
if v.size == 1
|
|
||||||
env.push_string v[0]
|
|
||||||
else
|
|
||||||
env.push_string v.join ","
|
|
||||||
end
|
|
||||||
env.put_prop_string -2, k
|
|
||||||
end
|
|
||||||
env.put_prop_string -2, "headers"
|
|
||||||
|
|
||||||
env.call_success
|
|
||||||
end
|
|
||||||
sbx.put_prop_string -2, "get"
|
|
||||||
|
|
||||||
sbx.push_proc LibDUK::VARARGS do |ptr|
|
|
||||||
env = Duktape::Sandbox.new ptr
|
|
||||||
url = env.require_string 0
|
|
||||||
body = env.require_string 1
|
|
||||||
|
|
||||||
headers = HTTP::Headers.new
|
|
||||||
|
|
||||||
if env.get_top == 3
|
|
||||||
env.enum 2, LibDUK::Enum::OwnPropertiesOnly
|
|
||||||
while env.next -1, true
|
|
||||||
key = env.require_string -2
|
|
||||||
val = env.require_string -1
|
|
||||||
headers.add key, val
|
|
||||||
env.pop_2
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
res = HTTP::Client.post url, headers, body
|
|
||||||
|
|
||||||
env.push_object
|
|
||||||
|
|
||||||
env.push_int res.status_code
|
|
||||||
env.put_prop_string -2, "status_code"
|
|
||||||
|
|
||||||
env.push_string res.body
|
|
||||||
env.put_prop_string -2, "body"
|
|
||||||
|
|
||||||
env.push_object
|
|
||||||
res.headers.each do |k, v|
|
|
||||||
if v.size == 1
|
|
||||||
env.push_string v[0]
|
|
||||||
else
|
|
||||||
env.push_string v.join ","
|
|
||||||
end
|
|
||||||
env.put_prop_string -2, k
|
|
||||||
end
|
|
||||||
env.put_prop_string -2, "headers"
|
|
||||||
|
|
||||||
env.call_success
|
|
||||||
end
|
|
||||||
sbx.put_prop_string -2, "post"
|
|
||||||
|
|
||||||
sbx.push_proc 2 do |ptr|
|
|
||||||
env = Duktape::Sandbox.new ptr
|
|
||||||
html = env.require_string 0
|
|
||||||
selector = env.require_string 1
|
|
||||||
|
|
||||||
myhtml = Myhtml::Parser.new html
|
|
||||||
ary = myhtml.css(selector).map(&.to_html).to_a
|
|
||||||
|
|
||||||
ary_idx = env.push_array
|
|
||||||
ary.each_with_index do |str, i|
|
|
||||||
env.push_string str
|
|
||||||
env.put_prop_index ary_idx, i.to_u32
|
|
||||||
end
|
|
||||||
|
|
||||||
env.call_success
|
|
||||||
end
|
|
||||||
sbx.put_prop_string -2, "css"
|
|
||||||
|
|
||||||
sbx.push_proc 1 do |ptr|
|
|
||||||
env = Duktape::Sandbox.new ptr
|
|
||||||
html = env.require_string 0
|
|
||||||
|
|
||||||
str = XML.parse(html).inner_text
|
|
||||||
|
|
||||||
env.push_string str
|
|
||||||
env.call_success
|
|
||||||
end
|
|
||||||
sbx.put_prop_string -2, "text"
|
|
||||||
|
|
||||||
sbx.push_proc 2 do |ptr|
|
|
||||||
env = Duktape::Sandbox.new ptr
|
|
||||||
html = env.require_string 0
|
|
||||||
name = env.require_string 1
|
|
||||||
|
|
||||||
begin
|
|
||||||
attr = XML.parse(html).first_element_child.not_nil![name]
|
|
||||||
env.push_string attr
|
|
||||||
rescue
|
|
||||||
env.push_undefined
|
|
||||||
end
|
|
||||||
|
|
||||||
env.call_success
|
|
||||||
end
|
|
||||||
sbx.put_prop_string -2, "attribute"
|
|
||||||
|
|
||||||
sbx.push_proc 1 do |ptr|
|
|
||||||
env = Duktape::Sandbox.new ptr
|
|
||||||
msg = env.require_string 0
|
|
||||||
env.call_success
|
|
||||||
|
|
||||||
raise PluginException.new msg
|
|
||||||
end
|
|
||||||
sbx.put_prop_string -2, "raise"
|
|
||||||
|
|
||||||
sbx.push_proc LibDUK::VARARGS do |ptr|
|
|
||||||
env = Duktape::Sandbox.new ptr
|
|
||||||
key = env.require_string 0
|
|
||||||
|
|
||||||
env.get_global_string "storage_path"
|
|
||||||
storage_path = env.require_string -1
|
|
||||||
env.pop
|
|
||||||
storage = Storage.new storage_path
|
|
||||||
|
|
||||||
if env.get_top == 2
|
|
||||||
val = env.require_string 1
|
|
||||||
storage[key] = val
|
|
||||||
storage.save
|
|
||||||
else
|
|
||||||
val = storage[key]?
|
|
||||||
if val
|
|
||||||
env.push_string val
|
|
||||||
else
|
|
||||||
env.push_undefined
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
env.call_success
|
|
||||||
end
|
|
||||||
sbx.put_prop_string -2, "storage"
|
|
||||||
|
|
||||||
sbx.put_prop_string -2, "mango"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
318
src/queue.cr
318
src/queue.cr
@@ -1,318 +0,0 @@
|
|||||||
require "sqlite3"
|
|
||||||
require "./util/*"
|
|
||||||
|
|
||||||
class Queue
|
|
||||||
abstract class Downloader
|
|
||||||
property stopped = false
|
|
||||||
@library_path : String = Config.current.library_path
|
|
||||||
@downloading = false
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
@queue = Queue.default
|
|
||||||
@queue << self
|
|
||||||
|
|
||||||
spawn do
|
|
||||||
loop do
|
|
||||||
sleep 1.second
|
|
||||||
next if @stopped || @downloading
|
|
||||||
begin
|
|
||||||
job = pop
|
|
||||||
next if job.nil?
|
|
||||||
download job
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
@downloading = false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
abstract def pop : Job?
|
|
||||||
private abstract def download(job : Job)
|
|
||||||
end
|
|
||||||
|
|
||||||
enum JobStatus
|
|
||||||
Pending # 0
|
|
||||||
Downloading # 1
|
|
||||||
Error # 2
|
|
||||||
Completed # 3
|
|
||||||
MissingPages # 4
|
|
||||||
end
|
|
||||||
|
|
||||||
struct Job
|
|
||||||
property id : String
|
|
||||||
property manga_id : String
|
|
||||||
property title : String
|
|
||||||
property manga_title : String
|
|
||||||
property status : JobStatus
|
|
||||||
property status_message : String = ""
|
|
||||||
property pages : Int32 = 0
|
|
||||||
property success_count : Int32 = 0
|
|
||||||
property fail_count : Int32 = 0
|
|
||||||
property time : Time
|
|
||||||
property plugin_id : String?
|
|
||||||
property plugin_chapter_id : String?
|
|
||||||
|
|
||||||
def parse_query_result(res : DB::ResultSet)
|
|
||||||
@id = res.read String
|
|
||||||
@manga_id = res.read String
|
|
||||||
@title = res.read String
|
|
||||||
@manga_title = res.read String
|
|
||||||
status = res.read Int32
|
|
||||||
@status_message = res.read String
|
|
||||||
@pages = res.read Int32
|
|
||||||
@success_count = res.read Int32
|
|
||||||
@fail_count = res.read Int32
|
|
||||||
time = res.read Int64
|
|
||||||
@status = JobStatus.new status
|
|
||||||
@time = Time.unix_ms time
|
|
||||||
|
|
||||||
ary = @id.split("-")
|
|
||||||
if ary.size == 2
|
|
||||||
@plugin_id = ary[0]
|
|
||||||
@plugin_chapter_id = ary[1]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Raises if the result set does not contain the correct set of columns
|
|
||||||
def self.from_query_result(res : DB::ResultSet)
|
|
||||||
job = Job.allocate
|
|
||||||
job.parse_query_result res
|
|
||||||
job
|
|
||||||
end
|
|
||||||
|
|
||||||
def initialize(@id, @manga_id, @title, @manga_title, @status, @time,
|
|
||||||
@plugin_id = nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_json(json)
|
|
||||||
json.object do
|
|
||||||
{% for name in ["id", "manga_id", "title", "manga_title",
|
|
||||||
"status_message"] %}
|
|
||||||
json.field {{name}}, @{{name.id}}
|
|
||||||
{% end %}
|
|
||||||
{% for name in ["pages", "success_count", "fail_count"] %}
|
|
||||||
json.field {{name}} do
|
|
||||||
json.number @{{name.id}}
|
|
||||||
end
|
|
||||||
{% end %}
|
|
||||||
json.field "status", @status.to_s
|
|
||||||
json.field "time" do
|
|
||||||
json.number @time.to_unix_ms
|
|
||||||
end
|
|
||||||
json.field "plugin_id", @plugin_id if @plugin_id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
getter path : String
|
|
||||||
@downloaders = [] of Downloader
|
|
||||||
@paused = false
|
|
||||||
|
|
||||||
use_default
|
|
||||||
|
|
||||||
def initialize(db_path : String? = nil)
|
|
||||||
@path = db_path || Config.current.mangadex["download_queue_db_path"].to_s
|
|
||||||
dir = File.dirname @path
|
|
||||||
unless Dir.exists? dir
|
|
||||||
Logger.info "The queue DB directory #{dir} does not exist. " \
|
|
||||||
"Attepmting to create it"
|
|
||||||
Dir.mkdir_p dir
|
|
||||||
end
|
|
||||||
MainFiber.run do
|
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
|
||||||
begin
|
|
||||||
db.exec "create table if not exists queue " \
|
|
||||||
"(id text, manga_id text, title text, manga_title " \
|
|
||||||
"text, status integer, status_message text, " \
|
|
||||||
"pages integer, success_count integer, " \
|
|
||||||
"fail_count integer, time integer)"
|
|
||||||
db.exec "create unique index if not exists id_idx " \
|
|
||||||
"on queue (id)"
|
|
||||||
db.exec "create index if not exists manga_id_idx " \
|
|
||||||
"on queue (manga_id)"
|
|
||||||
db.exec "create index if not exists status_idx " \
|
|
||||||
"on queue (status)"
|
|
||||||
rescue e
|
|
||||||
Logger.error "Error when checking tables in DB: #{e}"
|
|
||||||
raise e
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Push an array of jobs into the queue, and return the number of jobs
|
|
||||||
# inserted. Any job already exists in the queue will be ignored.
|
|
||||||
def push(jobs : Array(Job))
|
|
||||||
start_count = self.count
|
|
||||||
MainFiber.run do
|
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
|
||||||
jobs.each do |job|
|
|
||||||
db.exec "insert or ignore into queue values " \
|
|
||||||
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
job.id, job.manga_id, job.title, job.manga_title,
|
|
||||||
job.status.to_i, job.status_message, job.pages,
|
|
||||||
job.success_count, job.fail_count, job.time.to_unix_ms
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
self.count - start_count
|
|
||||||
end
|
|
||||||
|
|
||||||
def reset(id : String)
|
|
||||||
MainFiber.run do
|
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
|
||||||
db.exec "update queue set status = 0, status_message = '', " \
|
|
||||||
"pages = 0, success_count = 0, fail_count = 0 " \
|
|
||||||
"where id = (?)", id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def reset(job : Job)
|
|
||||||
self.reset job.id
|
|
||||||
end
|
|
||||||
|
|
||||||
# Reset all failed tasks (missing pages and error)
|
|
||||||
def reset
|
|
||||||
MainFiber.run do
|
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
|
||||||
db.exec "update queue set status = 0, status_message = '', " \
|
|
||||||
"pages = 0, success_count = 0, fail_count = 0 " \
|
|
||||||
"where status = 2 or status = 4"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def delete(id : String)
|
|
||||||
MainFiber.run do
|
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
|
||||||
db.exec "delete from queue where id = (?)", id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def delete(job : Job)
|
|
||||||
self.delete job.id
|
|
||||||
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)
|
|
||||||
MainFiber.run do
|
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
|
||||||
db.exec "delete from queue where status = (?)", status.to_i
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def count_status(status : JobStatus)
|
|
||||||
num = 0
|
|
||||||
MainFiber.run do
|
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
|
||||||
num = db.query_one "select count(*) from queue where " \
|
|
||||||
"status = (?)", status.to_i, as: Int32
|
|
||||||
end
|
|
||||||
end
|
|
||||||
num
|
|
||||||
end
|
|
||||||
|
|
||||||
def count
|
|
||||||
num = 0
|
|
||||||
MainFiber.run do
|
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
|
||||||
num = db.query_one "select count(*) from queue", as: Int32
|
|
||||||
end
|
|
||||||
end
|
|
||||||
num
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_status(status : JobStatus, job : Job)
|
|
||||||
MainFiber.run do
|
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
|
||||||
db.exec "update queue set status = (?) where id = (?)",
|
|
||||||
status.to_i, job.id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_all
|
|
||||||
jobs = [] of Job
|
|
||||||
MainFiber.run do
|
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
|
||||||
jobs = db.query_all "select * from queue order by time" do |rs|
|
|
||||||
Job.from_query_result rs
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
jobs
|
|
||||||
end
|
|
||||||
|
|
||||||
def add_success(job : Job)
|
|
||||||
MainFiber.run do
|
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
|
||||||
db.exec "update queue set success_count = success_count + 1 " \
|
|
||||||
"where id = (?)", job.id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def add_fail(job : Job)
|
|
||||||
MainFiber.run do
|
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
|
||||||
db.exec "update queue set fail_count = fail_count + 1 " \
|
|
||||||
"where id = (?)", job.id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_pages(pages : Int32, job : Job)
|
|
||||||
MainFiber.run do
|
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
|
||||||
db.exec "update queue set pages = (?), success_count = 0, " \
|
|
||||||
"fail_count = 0 where id = (?)", pages, job.id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def add_message(msg : String, job : Job)
|
|
||||||
MainFiber.run do
|
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
|
||||||
db.exec "update queue set status_message = " \
|
|
||||||
"status_message || (?) || (?) where id = (?)",
|
|
||||||
"\n", msg, job.id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def <<(downloader : Downloader)
|
|
||||||
@downloaders << downloader
|
|
||||||
end
|
|
||||||
|
|
||||||
def pause
|
|
||||||
@downloaders.each &.stopped=(true)
|
|
||||||
@paused = true
|
|
||||||
end
|
|
||||||
|
|
||||||
def resume
|
|
||||||
@downloaders.each &.stopped=(false)
|
|
||||||
@paused = false
|
|
||||||
end
|
|
||||||
|
|
||||||
def paused?
|
|
||||||
@paused
|
|
||||||
end
|
|
||||||
end
|
|
||||||
151
src/rename.cr
151
src/rename.cr
@@ -1,151 +0,0 @@
|
|||||||
module Rename
|
|
||||||
alias VHash = Hash(String, String)
|
|
||||||
|
|
||||||
abstract class Base(T)
|
|
||||||
@ary = [] of T
|
|
||||||
|
|
||||||
def push(var)
|
|
||||||
@ary.push var
|
|
||||||
end
|
|
||||||
|
|
||||||
abstract def render(hash : VHash)
|
|
||||||
end
|
|
||||||
|
|
||||||
class Variable < Base(String)
|
|
||||||
property id : String
|
|
||||||
|
|
||||||
def initialize(@id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def render(hash : VHash)
|
|
||||||
hash[@id]? || ""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Pattern < Base(Variable)
|
|
||||||
def render(hash : VHash)
|
|
||||||
@ary.each do |v|
|
|
||||||
if hash.has_key? v.id
|
|
||||||
return v.render hash
|
|
||||||
end
|
|
||||||
end
|
|
||||||
""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Group < Base(Pattern | String)
|
|
||||||
def render(hash : VHash)
|
|
||||||
return "" if @ary.select(Pattern)
|
|
||||||
.any? &.as(Pattern).render(hash).empty?
|
|
||||||
@ary.join do |e|
|
|
||||||
if e.is_a? Pattern
|
|
||||||
e.render hash
|
|
||||||
else
|
|
||||||
e
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Rule < Base(Group | String | Pattern)
|
|
||||||
ESCAPE = ['/']
|
|
||||||
|
|
||||||
def initialize(str : String)
|
|
||||||
parse! str
|
|
||||||
rescue e
|
|
||||||
raise "Failed to parse rename rule #{str}. Error: #{e}"
|
|
||||||
end
|
|
||||||
|
|
||||||
private def parse!(str : String)
|
|
||||||
chars = [] of Char
|
|
||||||
pattern : Pattern? = nil
|
|
||||||
group : Group? = nil
|
|
||||||
|
|
||||||
str.each_char_with_index do |char, i|
|
|
||||||
if ['[', ']', '{', '}', '|'].includes?(char) && !chars.empty?
|
|
||||||
string = chars.join
|
|
||||||
if !pattern.nil?
|
|
||||||
pattern.push Variable.new string.strip
|
|
||||||
elsif !group.nil?
|
|
||||||
group.push string
|
|
||||||
else
|
|
||||||
@ary.push string
|
|
||||||
end
|
|
||||||
chars = [] of Char
|
|
||||||
end
|
|
||||||
|
|
||||||
case char
|
|
||||||
when '['
|
|
||||||
if !group.nil? || !pattern.nil?
|
|
||||||
raise "nested groups are not allowed"
|
|
||||||
end
|
|
||||||
group = Group.new
|
|
||||||
when ']'
|
|
||||||
if group.nil?
|
|
||||||
raise "unmatched ] at position #{i}"
|
|
||||||
end
|
|
||||||
if !pattern.nil?
|
|
||||||
raise "patterns (`{}`) should be closed before closing the " \
|
|
||||||
"group (`[]`)"
|
|
||||||
end
|
|
||||||
@ary.push group
|
|
||||||
group = nil
|
|
||||||
when '{'
|
|
||||||
if !pattern.nil?
|
|
||||||
raise "nested patterns are not allowed"
|
|
||||||
end
|
|
||||||
pattern = Pattern.new
|
|
||||||
when '}'
|
|
||||||
if pattern.nil?
|
|
||||||
raise "unmatched } at position #{i}"
|
|
||||||
end
|
|
||||||
if !group.nil?
|
|
||||||
group.push pattern
|
|
||||||
else
|
|
||||||
@ary.push pattern
|
|
||||||
end
|
|
||||||
pattern = nil
|
|
||||||
when '|'
|
|
||||||
if pattern.nil?
|
|
||||||
chars.push char
|
|
||||||
end
|
|
||||||
else
|
|
||||||
if ESCAPE.includes? char
|
|
||||||
raise "the character #{char} at position #{i} is not allowed"
|
|
||||||
end
|
|
||||||
chars.push char
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
unless chars.empty?
|
|
||||||
@ary.push chars.join
|
|
||||||
end
|
|
||||||
if !pattern.nil?
|
|
||||||
raise "unclosed pattern {"
|
|
||||||
end
|
|
||||||
if !group.nil?
|
|
||||||
raise "unclosed group ["
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def render(hash : VHash)
|
|
||||||
str = @ary.join do |e|
|
|
||||||
if e.is_a? String
|
|
||||||
e
|
|
||||||
else
|
|
||||||
e.render hash
|
|
||||||
end
|
|
||||||
end.strip
|
|
||||||
post_process str
|
|
||||||
end
|
|
||||||
|
|
||||||
# Post-processes the generated file/folder name
|
|
||||||
# - Handles the rare case where the string is `..`
|
|
||||||
# - Removes trailing spaces and periods
|
|
||||||
# - Replace illegal characters with `_`
|
|
||||||
private def post_process(str)
|
|
||||||
return "_" if str == ".."
|
|
||||||
str.rstrip(" .").gsub /[\/?<>\\:*|"^]/, "_"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
struct AdminRouter
|
require "./router"
|
||||||
def initialize
|
|
||||||
|
class AdminRouter < Router
|
||||||
|
def setup
|
||||||
get "/admin" do |env|
|
get "/admin" do |env|
|
||||||
storage = Storage.default
|
|
||||||
missing_count = storage.missing_titles.size +
|
|
||||||
storage.missing_entries.size
|
|
||||||
layout "admin"
|
layout "admin"
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/admin/user" do |env|
|
get "/admin/user" do |env|
|
||||||
users = Storage.default.list_users
|
users = @context.storage.list_users
|
||||||
username = get_username env
|
username = get_username env
|
||||||
layout "user"
|
layout "user"
|
||||||
end
|
end
|
||||||
@@ -33,15 +32,29 @@ struct AdminRouter
|
|||||||
# would not contain `admin`
|
# would not contain `admin`
|
||||||
admin = !env.params.body["admin"]?.nil?
|
admin = !env.params.body["admin"]?.nil?
|
||||||
|
|
||||||
Storage.default.new_user username, password, admin
|
if username.size < 3
|
||||||
|
raise "Username should contain at least 3 characters"
|
||||||
|
end
|
||||||
|
if (username =~ /^[A-Za-z0-9_]+$/).nil?
|
||||||
|
raise "Username should contain alphanumeric characters " \
|
||||||
|
"and underscores only"
|
||||||
|
end
|
||||||
|
if password.size < 6
|
||||||
|
raise "Password should contain at least 6 characters"
|
||||||
|
end
|
||||||
|
if (password =~ /^[[:ascii:]]+$/).nil?
|
||||||
|
raise "password should contain ASCII characters only"
|
||||||
|
end
|
||||||
|
|
||||||
redirect env, "/admin/user"
|
@context.storage.new_user username, password, admin
|
||||||
|
|
||||||
|
env.redirect "/admin/user"
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
@context.error e
|
||||||
redirect_url = URI.new \
|
redirect_url = URI.new \
|
||||||
path: "/admin/user/edit",
|
path: "/admin/user/edit",
|
||||||
query: hash_to_query({"error" => e.message})
|
query: hash_to_query({"error" => e.message})
|
||||||
redirect env, redirect_url.to_s
|
env.redirect redirect_url.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
post "/admin/user/edit/:original_username" do |env|
|
post "/admin/user/edit/:original_username" do |env|
|
||||||
@@ -52,26 +65,39 @@ struct AdminRouter
|
|||||||
admin = !env.params.body["admin"]?.nil?
|
admin = !env.params.body["admin"]?.nil?
|
||||||
original_username = env.params.url["original_username"]
|
original_username = env.params.url["original_username"]
|
||||||
|
|
||||||
Storage.default.update_user \
|
if username.size < 3
|
||||||
|
raise "Username should contain at least 3 characters"
|
||||||
|
end
|
||||||
|
if (username =~ /^[A-Za-z0-9_]+$/).nil?
|
||||||
|
raise "Username should contain alphanumeric characters " \
|
||||||
|
"and underscores only"
|
||||||
|
end
|
||||||
|
|
||||||
|
if password.size != 0
|
||||||
|
if password.size < 6
|
||||||
|
raise "Password should contain at least 6 characters"
|
||||||
|
end
|
||||||
|
if (password =~ /^[[:ascii:]]+$/).nil?
|
||||||
|
raise "password should contain ASCII characters only"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@context.storage.update_user \
|
||||||
original_username, username, password, admin
|
original_username, username, password, admin
|
||||||
|
|
||||||
redirect env, "/admin/user"
|
env.redirect "/admin/user"
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
@context.error e
|
||||||
redirect_url = URI.new \
|
redirect_url = URI.new \
|
||||||
path: "/admin/user/edit",
|
path: "/admin/user/edit",
|
||||||
query: hash_to_query({"username" => original_username, \
|
query: hash_to_query({"username" => original_username, \
|
||||||
"admin" => admin, "error" => e.message})
|
"admin" => admin, "error" => e.message})
|
||||||
redirect env, redirect_url.to_s
|
env.redirect redirect_url.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/admin/downloads" do |env|
|
get "/admin/downloads" do |env|
|
||||||
mangadex_base_url = Config.current.mangadex["base_url"]
|
base_url = @context.config.mangadex["base_url"]
|
||||||
layout "download-manager"
|
layout "download-manager"
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/admin/missing" do |env|
|
|
||||||
layout "missing-items"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,100 +1,16 @@
|
|||||||
|
require "./router"
|
||||||
|
require "../mangadex/*"
|
||||||
require "../upload"
|
require "../upload"
|
||||||
require "koa"
|
|
||||||
require "digest"
|
|
||||||
|
|
||||||
struct APIRouter
|
class APIRouter < Router
|
||||||
@@api_json : String?
|
def setup
|
||||||
|
|
||||||
API_VERSION = "0.1.0"
|
|
||||||
|
|
||||||
macro s(fields)
|
|
||||||
{
|
|
||||||
{% for field in fields %}
|
|
||||||
{{field}} => String,
|
|
||||||
{% end %}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
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 except `/api/login` require authentication. After logging in, your session ID would be stored as a cookie named `mango-sessid-#{Config.current.port}`, which can be used to authenticate the API access. Note that all admin API endpoints (`/api/admin/...`) require the logged-in user to have admin access.
|
|
||||||
|
|
||||||
# Terminologies
|
|
||||||
|
|
||||||
- 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.define_tag "admin", desc: <<-MD
|
|
||||||
These are the admin endpoints only accessible for users with admin access. A non-admin user will get HTTP 403 when calling the endpoints.
|
|
||||||
MD
|
|
||||||
|
|
||||||
Koa.schema "entry", {
|
|
||||||
"pages" => Int32,
|
|
||||||
"mtime" => Int64,
|
|
||||||
}.merge(s %w(zip_path title size id title_id display_name cover_url)),
|
|
||||||
desc: "An entry in a book"
|
|
||||||
|
|
||||||
Koa.schema "title", {
|
|
||||||
"mtime" => Int64,
|
|
||||||
"entries" => ["entry"],
|
|
||||||
"titles" => ["title"],
|
|
||||||
"parents" => [String],
|
|
||||||
}.merge(s %w(dir title id display_name cover_url)),
|
|
||||||
desc: "A manga title (a collection of entries and sub-titles)"
|
|
||||||
|
|
||||||
Koa.schema "result", {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
}
|
|
||||||
|
|
||||||
Koa.describe "Authenticates a user", <<-MD
|
|
||||||
After successful login, the cookie `mango-sessid-#{Config.current.port}` will contain a valid session ID that can be used for subsequent requests
|
|
||||||
MD
|
|
||||||
Koa.body schema: {
|
|
||||||
"username" => String,
|
|
||||||
"password" => String,
|
|
||||||
}
|
|
||||||
Koa.tag "users"
|
|
||||||
post "/api/login" do |env|
|
|
||||||
begin
|
|
||||||
username = env.params.json["username"].as String
|
|
||||||
password = env.params.json["password"].as String
|
|
||||||
token = Storage.default.verify_user(username, password).not_nil!
|
|
||||||
|
|
||||||
env.session.string "token", token
|
|
||||||
"Authenticated"
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
env.response.status_code = 403
|
|
||||||
e.message
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Returns a page in a manga entry"
|
|
||||||
Koa.path "tid", desc: "Title ID"
|
|
||||||
Koa.path "eid", desc: "Entry ID"
|
|
||||||
Koa.path "page", schema: Int32, desc: "The page number to return (starts from 1)"
|
|
||||||
Koa.response 200, schema: Bytes, media_type: "image/*"
|
|
||||||
Koa.response 500, "Page not found or not readable"
|
|
||||||
Koa.response 304, "Page not modified (only available when `If-None-Match` is set)"
|
|
||||||
Koa.tag "reader"
|
|
||||||
get "/api/page/:tid/:eid/:page" do |env|
|
get "/api/page/:tid/:eid/:page" do |env|
|
||||||
begin
|
begin
|
||||||
tid = env.params.url["tid"]
|
tid = env.params.url["tid"]
|
||||||
eid = env.params.url["eid"]
|
eid = env.params.url["eid"]
|
||||||
page = env.params.url["page"].to_i
|
page = env.params.url["page"].to_i
|
||||||
prev_e_tag = env.request.headers["If-None-Match"]?
|
|
||||||
|
|
||||||
title = Library.default.get_title tid
|
title = @context.library.get_title tid
|
||||||
raise "Title ID `#{tid}` not found" if title.nil?
|
raise "Title ID `#{tid}` not found" if title.nil?
|
||||||
entry = title.get_entry eid
|
entry = title.get_entry eid
|
||||||
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
||||||
@@ -102,165 +18,48 @@ struct APIRouter
|
|||||||
raise "Failed to load page #{page} of " \
|
raise "Failed to load page #{page} of " \
|
||||||
"`#{title.title}/#{entry.title}`" if img.nil?
|
"`#{title.title}/#{entry.title}`" if img.nil?
|
||||||
|
|
||||||
e_tag = Digest::SHA1.hexdigest img.data
|
|
||||||
if prev_e_tag == e_tag
|
|
||||||
env.response.status_code = 304
|
|
||||||
""
|
|
||||||
else
|
|
||||||
env.response.headers["ETag"] = e_tag
|
|
||||||
env.response.headers["Cache-Control"] = "public, max-age=86400"
|
|
||||||
send_img env, img
|
send_img env, img
|
||||||
end
|
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
@context.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
e.message
|
e.message
|
||||||
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, schema: Bytes, media_type: "image/*"
|
|
||||||
Koa.response 304, "Page not modified (only available when `If-None-Match` is set)"
|
|
||||||
Koa.response 500, "Page not found or not readable"
|
|
||||||
Koa.tag "library"
|
|
||||||
get "/api/cover/:tid/:eid" do |env|
|
|
||||||
begin
|
|
||||||
tid = env.params.url["tid"]
|
|
||||||
eid = env.params.url["eid"]
|
|
||||||
prev_e_tag = env.request.headers["If-None-Match"]?
|
|
||||||
|
|
||||||
title = Library.default.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?
|
|
||||||
|
|
||||||
e_tag = Digest::SHA1.hexdigest img.data
|
|
||||||
if prev_e_tag == e_tag
|
|
||||||
env.response.status_code = 304
|
|
||||||
""
|
|
||||||
else
|
|
||||||
env.response.headers["ETag"] = e_tag
|
|
||||||
send_img env, img
|
|
||||||
end
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
env.response.status_code = 500
|
|
||||||
e.message
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Returns the book with title `tid`", <<-MD
|
|
||||||
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
|
||||||
- Supply the `depth` query parameter to control the depth of nested titles to return.
|
|
||||||
- When `depth` is 1, returns the top-level titles and sub-titles/entries one level in them
|
|
||||||
- When `depth` is 0, returns the top-level titles without their sub-titles/entries
|
|
||||||
- When `depth` is N, returns the top-level titles and sub-titles/entries N levels in them
|
|
||||||
- When `depth` is negative, returns the entire library
|
|
||||||
MD
|
|
||||||
Koa.path "tid", desc: "Title ID"
|
|
||||||
Koa.query "slim"
|
|
||||||
Koa.query "depth"
|
|
||||||
Koa.query "sort", desc: "Sorting option for entries. Can be one of 'auto', 'title', 'progress', 'time_added' and 'time_modified'"
|
|
||||||
Koa.query "ascend", desc: "Sorting direction for entries. Set to 0 for the descending order. Doesn't work without specifying 'sort'"
|
|
||||||
Koa.response 200, schema: "title"
|
|
||||||
Koa.response 404, "Title not found"
|
|
||||||
Koa.tag "library"
|
|
||||||
get "/api/book/:tid" do |env|
|
get "/api/book/:tid" do |env|
|
||||||
begin
|
begin
|
||||||
username = get_username env
|
|
||||||
|
|
||||||
sort_opt = SortOptions.new
|
|
||||||
get_sort_opt
|
|
||||||
|
|
||||||
tid = env.params.url["tid"]
|
tid = env.params.url["tid"]
|
||||||
title = Library.default.get_title tid
|
title = @context.library.get_title tid
|
||||||
raise "Title ID `#{tid}` not found" if title.nil?
|
raise "Title ID `#{tid}` not found" if title.nil?
|
||||||
|
|
||||||
slim = !env.params.query["slim"]?.nil?
|
send_json env, title.to_json
|
||||||
depth = env.params.query["depth"]?.try(&.to_i?) || -1
|
|
||||||
|
|
||||||
send_json env, title.build_json(slim: slim, depth: depth,
|
|
||||||
sort_context: {username: username,
|
|
||||||
opt: sort_opt})
|
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
@context.error e
|
||||||
env.response.status_code = 404
|
env.response.status_code = 500
|
||||||
e.message
|
e.message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Returns the entire library with all titles and entries", <<-MD
|
get "/api/book" do |env|
|
||||||
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
send_json env, @context.library.to_json
|
||||||
- Supply the `dpeth` query parameter to control the depth of nested titles to return.
|
|
||||||
- When `depth` is 1, returns the requested title and sub-titles/entries one level in it
|
|
||||||
- When `depth` is 0, returns the requested title without its sub-titles/entries
|
|
||||||
- When `depth` is N, returns the requested title and sub-titles/entries N levels in it
|
|
||||||
- When `depth` is negative, returns the requested title and all sub-titles/entries in it
|
|
||||||
MD
|
|
||||||
Koa.query "slim"
|
|
||||||
Koa.query "depth"
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"dir" => String,
|
|
||||||
"titles" => ["title"],
|
|
||||||
}
|
|
||||||
Koa.tag "library"
|
|
||||||
get "/api/library" do |env|
|
|
||||||
slim = !env.params.query["slim"]?.nil?
|
|
||||||
depth = env.params.query["depth"]?.try(&.to_i?) || -1
|
|
||||||
|
|
||||||
send_json env, Library.default.build_json(slim: slim, depth: depth)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Triggers a library scan"
|
|
||||||
Koa.tags ["admin", "library"]
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"milliseconds" => Float64,
|
|
||||||
"titles" => Int32,
|
|
||||||
}
|
|
||||||
post "/api/admin/scan" do |env|
|
post "/api/admin/scan" do |env|
|
||||||
start = Time.utc
|
start = Time.utc
|
||||||
Library.default.scan
|
@context.library.scan
|
||||||
ms = (Time.utc - start).total_milliseconds
|
ms = (Time.utc - start).total_milliseconds
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"milliseconds" => ms,
|
"milliseconds" => ms,
|
||||||
"titles" => Library.default.titles.size,
|
"titles" => @context.library.titles.size,
|
||||||
}.to_json
|
}.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Returns the thumbnail generation progress between 0 and 1"
|
post "/api/admin/user/delete/:username" do |env|
|
||||||
Koa.tags ["admin", "library"]
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"progress" => Float64,
|
|
||||||
}
|
|
||||||
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.tags ["admin", "library"]
|
|
||||||
post "/api/admin/generate_thumbnails" do |env|
|
|
||||||
spawn do
|
|
||||||
Library.default.generate_thumbnails
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Deletes a user with `username`"
|
|
||||||
Koa.tags ["admin", "users"]
|
|
||||||
Koa.response 200, schema: "result"
|
|
||||||
delete "/api/admin/user/delete/:username" do |env|
|
|
||||||
begin
|
begin
|
||||||
username = env.params.url["username"]
|
username = env.params.url["username"]
|
||||||
Storage.default.delete_user username
|
@context.storage.delete_user username
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
@context.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -270,37 +69,25 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Updates the reading progress of an entry or the whole title for the current user", <<-MD
|
post "/api/progress/:title/:page" do |env|
|
||||||
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, schema: "result"
|
|
||||||
Koa.tag "progress"
|
|
||||||
put "/api/progress/:tid/:page" do |env|
|
|
||||||
begin
|
begin
|
||||||
username = get_username env
|
username = get_username env
|
||||||
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
title = (@context.library.get_title env.params.url["title"])
|
||||||
|
.not_nil!
|
||||||
page = env.params.url["page"].to_i
|
page = env.params.url["page"].to_i
|
||||||
entry_id = env.params.query["eid"]?
|
entry_id = env.params.query["entry"]?
|
||||||
|
|
||||||
if !entry_id.nil?
|
if !entry_id.nil?
|
||||||
entry = title.get_entry(entry_id).not_nil!
|
entry = title.get_entry(entry_id).not_nil!
|
||||||
raise "incorrect page value" if page < 0 || page > entry.pages
|
raise "incorrect page value" if page < 0 || page > entry.pages
|
||||||
entry.save_progress username, page
|
title.save_progress username, entry.title, page
|
||||||
elsif page == 0
|
elsif page == 0
|
||||||
title.unread_all username
|
title.unread_all username
|
||||||
else
|
else
|
||||||
title.read_all username
|
title.read_all username
|
||||||
end
|
end
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
@context.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -310,50 +97,12 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Updates the reading progress of multiple entries in a title"
|
post "/api/admin/display_name/:title/:name" do |env|
|
||||||
Koa.path "action", desc: "The action to perform. Can be either `read` or `unread`"
|
|
||||||
Koa.path "tid", desc: "Title ID"
|
|
||||||
Koa.body schema: {
|
|
||||||
"ids" => [String],
|
|
||||||
}, desc: "An array of entry IDs"
|
|
||||||
Koa.response 200, schema: "result"
|
|
||||||
Koa.tag "progress"
|
|
||||||
put "/api/bulk_progress/:action/:tid" do |env|
|
|
||||||
begin
|
begin
|
||||||
username = get_username env
|
title = (@context.library.get_title env.params.url["title"])
|
||||||
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
|
||||||
action = env.params.url["action"]
|
|
||||||
ids = env.params.json["ids"].as(Array).map &.as_s
|
|
||||||
|
|
||||||
unless action.in? ["read", "unread"]
|
|
||||||
raise "Unknow action #{action}"
|
|
||||||
end
|
|
||||||
title.bulk_progress action, ids, username
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
else
|
|
||||||
send_json env, {"success" => true}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
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.tags ["admin", "library"]
|
|
||||||
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, schema: "result"
|
|
||||||
put "/api/admin/display_name/:tid/:name" do |env|
|
|
||||||
begin
|
|
||||||
title = (Library.default.get_title env.params.url["tid"])
|
|
||||||
.not_nil!
|
.not_nil!
|
||||||
name = env.params.url["name"]
|
name = env.params.url["name"]
|
||||||
entry = env.params.query["eid"]?
|
entry = env.params.query["entry"]?
|
||||||
if entry.nil?
|
if entry.nil?
|
||||||
title.set_display_name name
|
title.set_display_name name
|
||||||
else
|
else
|
||||||
@@ -361,7 +110,7 @@ struct APIRouter
|
|||||||
title.set_display_name eobj.not_nil!.title, name
|
title.set_display_name eobj.not_nil!.title, name
|
||||||
end
|
end
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
@context.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -371,42 +120,51 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
ws "/api/admin/mangadex/queue" do |socket, env|
|
get "/api/admin/mangadex/manga/:id" do |env|
|
||||||
interval_raw = env.params.query["interval"]?
|
begin
|
||||||
interval = (interval_raw.to_i? if interval_raw) || 5
|
id = env.params.url["id"]
|
||||||
loop do
|
api = MangaDex::API.new @context.config.mangadex["api_url"].to_s
|
||||||
socket.send({
|
manga = api.get_manga id
|
||||||
"jobs" => Queue.default.get_all.reverse,
|
send_json env, manga.to_info_json
|
||||||
"paused" => Queue.default.paused?,
|
rescue e
|
||||||
}.to_json)
|
@context.error e
|
||||||
sleep interval.seconds
|
send_json env, {"error" => e.message}.to_json
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Returns the current download queue", <<-MD
|
post "/api/admin/mangadex/download" do |env|
|
||||||
On error, returns a JSON that contains the error message in the `error` field.
|
begin
|
||||||
MD
|
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
|
||||||
Koa.tags ["admin", "downloader"]
|
jobs = chapters.map { |chapter|
|
||||||
Koa.response 200, schema: {
|
MangaDex::Job.new(
|
||||||
"success" => Bool,
|
chapter["id"].as_s,
|
||||||
"error" => String?,
|
chapter["manga_id"].as_s,
|
||||||
"paused" => Bool?,
|
chapter["full_title"].as_s,
|
||||||
"jobs?" => [{
|
chapter["manga_title"].as_s,
|
||||||
"pages" => Int32,
|
MangaDex::JobStatus::Pending,
|
||||||
"success_count" => Int32,
|
Time.unix chapter["time"].as_s.to_i
|
||||||
"fail_count" => Int32,
|
)
|
||||||
"time" => Int64,
|
|
||||||
}.merge(s %w(id manga_id title manga_title status_message status))],
|
|
||||||
}
|
}
|
||||||
|
inserted_count = @context.queue.push jobs
|
||||||
|
send_json env, {
|
||||||
|
"success": inserted_count,
|
||||||
|
"fail": jobs.size - inserted_count,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
@context.error e
|
||||||
|
send_json env, {"error" => e.message}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
get "/api/admin/mangadex/queue" do |env|
|
get "/api/admin/mangadex/queue" do |env|
|
||||||
begin
|
begin
|
||||||
|
jobs = @context.queue.get_all
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"jobs" => Queue.default.get_all.reverse,
|
"jobs" => jobs,
|
||||||
"paused" => Queue.default.paused?,
|
"paused" => @context.queue.paused?,
|
||||||
"success" => true,
|
"success" => true,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -414,19 +172,6 @@ struct APIRouter
|
|||||||
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.tags ["admin", "downloader"]
|
|
||||||
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, schema: "result"
|
|
||||||
post "/api/admin/mangadex/queue/:action" do |env|
|
post "/api/admin/mangadex/queue/:action" do |env|
|
||||||
begin
|
begin
|
||||||
action = env.params.url["action"]
|
action = env.params.url["action"]
|
||||||
@@ -434,27 +179,26 @@ struct APIRouter
|
|||||||
case action
|
case action
|
||||||
when "delete"
|
when "delete"
|
||||||
if id.nil?
|
if id.nil?
|
||||||
Queue.default.delete_status Queue::JobStatus::Completed
|
@context.queue.delete_status MangaDex::JobStatus::Completed
|
||||||
else
|
else
|
||||||
Queue.default.delete id
|
@context.queue.delete id
|
||||||
end
|
end
|
||||||
when "retry"
|
when "retry"
|
||||||
if id.nil?
|
if id.nil?
|
||||||
Queue.default.reset
|
@context.queue.reset
|
||||||
else
|
else
|
||||||
Queue.default.reset id
|
@context.queue.reset id
|
||||||
end
|
end
|
||||||
when "pause"
|
when "pause"
|
||||||
Queue.default.pause
|
@context.queue.pause
|
||||||
when "resume"
|
when "resume"
|
||||||
Queue.default.resume
|
@context.queue.resume
|
||||||
else
|
else
|
||||||
raise "Unknown queue action #{action}"
|
raise "Unknown queue action #{action}"
|
||||||
end
|
end
|
||||||
|
|
||||||
send_json env, {"success" => true}.to_json
|
send_json env, {"success" => true}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -462,24 +206,6 @@ struct APIRouter
|
|||||||
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 media_type: "multipart/form-data", schema: {
|
|
||||||
"file" => Bytes,
|
|
||||||
}
|
|
||||||
Koa.response 200, schema: "result"
|
|
||||||
post "/api/admin/upload/:target" do |env|
|
post "/api/admin/upload/:target" do |env|
|
||||||
begin
|
begin
|
||||||
target = env.params.url["target"]
|
target = env.params.url["target"]
|
||||||
@@ -494,17 +220,17 @@ struct APIRouter
|
|||||||
|
|
||||||
case target
|
case target
|
||||||
when "cover"
|
when "cover"
|
||||||
title_id = env.params.query["tid"]
|
title_id = env.params.query["title"]
|
||||||
entry_id = env.params.query["eid"]?
|
entry_id = env.params.query["entry"]?
|
||||||
title = Library.default.get_title(title_id).not_nil!
|
title = @context.library.get_title(title_id).not_nil!
|
||||||
|
|
||||||
unless SUPPORTED_IMG_TYPES.includes? \
|
unless ["image/jpeg", "image/png"].includes? \
|
||||||
MIME.from_filename? filename
|
MIME.from_filename? filename
|
||||||
raise "The uploaded image must be either JPEG or PNG"
|
raise "The uploaded image must be either JPEG or PNG"
|
||||||
end
|
end
|
||||||
|
|
||||||
ext = File.extname filename
|
ext = File.extname filename
|
||||||
upload = Upload.new Config.current.upload_path
|
upload = Upload.new @context.config.upload_path, @context.logger
|
||||||
url = upload.path_to_url upload.save "img", ext, part.body
|
url = upload.path_to_url upload.save "img", ext, part.body
|
||||||
|
|
||||||
if url.nil?
|
if url.nil?
|
||||||
@@ -527,399 +253,11 @@ struct APIRouter
|
|||||||
|
|
||||||
raise "No part with name `file` found"
|
raise "No part with name `file` found"
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
}.to_json
|
}.to_json
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Lists the chapters in a title from a plugin"
|
|
||||||
Koa.tags ["admin", "downloader"]
|
|
||||||
Koa.query "plugin", schema: String
|
|
||||||
Koa.query "query", schema: String
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"chapters?" => [{
|
|
||||||
"id" => String,
|
|
||||||
"title" => String,
|
|
||||||
}],
|
|
||||||
"title" => String?,
|
|
||||||
}
|
|
||||||
get "/api/admin/plugin/list" do |env|
|
|
||||||
begin
|
|
||||||
query = env.params.query["query"].as String
|
|
||||||
plugin = Plugin.new env.params.query["plugin"].as String
|
|
||||||
|
|
||||||
json = plugin.list_chapters query
|
|
||||||
chapters = json["chapters"]
|
|
||||||
title = json["title"]
|
|
||||||
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"chapters" => chapters,
|
|
||||||
"title" => title,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Adds a list of chapters from a plugin to the download queue"
|
|
||||||
Koa.tags ["admin", "downloader"]
|
|
||||||
Koa.body schema: {
|
|
||||||
"plugin" => String,
|
|
||||||
"title" => String,
|
|
||||||
"chapters" => [{
|
|
||||||
"id" => String,
|
|
||||||
"title" => String,
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Int32,
|
|
||||||
"fail" => Int32,
|
|
||||||
}
|
|
||||||
post "/api/admin/plugin/download" do |env|
|
|
||||||
begin
|
|
||||||
plugin = Plugin.new env.params.json["plugin"].as String
|
|
||||||
chapters = env.params.json["chapters"].as Array(JSON::Any)
|
|
||||||
manga_title = env.params.json["title"].as String
|
|
||||||
|
|
||||||
jobs = chapters.map { |ch|
|
|
||||||
Queue::Job.new(
|
|
||||||
"#{plugin.info.id}-#{ch["id"]}",
|
|
||||||
"", # manga_id
|
|
||||||
ch["title"].as_s,
|
|
||||||
manga_title,
|
|
||||||
Queue::JobStatus::Pending,
|
|
||||||
Time.utc
|
|
||||||
)
|
|
||||||
}
|
|
||||||
inserted_count = Queue.default.push jobs
|
|
||||||
send_json env, {
|
|
||||||
"success": inserted_count,
|
|
||||||
"fail": jobs.size - inserted_count,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
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.tag "reader"
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"dimensions?" => [{
|
|
||||||
"width" => Int32,
|
|
||||||
"height" => Int32,
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
Koa.response 304, "Not modified (only available when `If-None-Match` is set)"
|
|
||||||
get "/api/dimensions/:tid/:eid" do |env|
|
|
||||||
begin
|
|
||||||
tid = env.params.url["tid"]
|
|
||||||
eid = env.params.url["eid"]
|
|
||||||
prev_e_tag = env.request.headers["If-None-Match"]?
|
|
||||||
|
|
||||||
title = Library.default.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?
|
|
||||||
|
|
||||||
file_hash = Digest::SHA1.hexdigest (entry.zip_path + entry.mtime.to_s)
|
|
||||||
e_tag = "W/#{file_hash}"
|
|
||||||
if e_tag == prev_e_tag
|
|
||||||
env.response.status_code = 304
|
|
||||||
""
|
|
||||||
else
|
|
||||||
sizes = entry.page_dimensions
|
|
||||||
env.response.headers["ETag"] = e_tag
|
|
||||||
env.response.headers["Cache-Control"] = "public, max-age=86400"
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"dimensions" => sizes,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Downloads an entry"
|
|
||||||
Koa.path "tid", desc: "A title ID"
|
|
||||||
Koa.path "eid", desc: "An entry ID"
|
|
||||||
Koa.response 200, schema: Bytes
|
|
||||||
Koa.response 404, "Entry not found"
|
|
||||||
Koa.tags ["library", "reader"]
|
|
||||||
get "/api/download/:tid/:eid" do |env|
|
|
||||||
begin
|
|
||||||
title = (Library.default.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
|
|
||||||
Logger.error e
|
|
||||||
env.response.status_code = 404
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Gets the tags of a title"
|
|
||||||
Koa.path "tid", desc: "A title ID"
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"tags" => [String?],
|
|
||||||
}
|
|
||||||
Koa.tags ["library", "tags"]
|
|
||||||
get "/api/tags/:tid" do |env|
|
|
||||||
begin
|
|
||||||
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
|
||||||
tags = title.tags
|
|
||||||
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"tags" => tags,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Returns all tags"
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"tags" => [String?],
|
|
||||||
}
|
|
||||||
Koa.tags ["library", "tags"]
|
|
||||||
get "/api/tags" do |env|
|
|
||||||
begin
|
|
||||||
tags = Storage.default.list_tags
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"tags" => tags,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Adds a new tag to a title"
|
|
||||||
Koa.path "tid", desc: "A title ID"
|
|
||||||
Koa.response 200, schema: "result"
|
|
||||||
Koa.tags ["admin", "library", "tags"]
|
|
||||||
put "/api/admin/tags/:tid/:tag" do |env|
|
|
||||||
begin
|
|
||||||
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
|
||||||
tag = env.params.url["tag"]
|
|
||||||
|
|
||||||
title.add_tag tag
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Deletes a tag from a title"
|
|
||||||
Koa.path "tid", desc: "A title ID"
|
|
||||||
Koa.response 200, schema: "result"
|
|
||||||
Koa.tags ["admin", "library", "tags"]
|
|
||||||
delete "/api/admin/tags/:tid/:tag" do |env|
|
|
||||||
begin
|
|
||||||
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
|
||||||
tag = env.params.url["tag"]
|
|
||||||
|
|
||||||
title.delete_tag tag
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Lists all missing titles"
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"titles?" => [{
|
|
||||||
"path" => String,
|
|
||||||
"id" => String,
|
|
||||||
"signature" => String,
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
Koa.tags ["admin", "library"]
|
|
||||||
get "/api/admin/titles/missing" do |env|
|
|
||||||
begin
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
"titles" => Storage.default.missing_titles,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Lists all missing entries"
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"entries?" => [{
|
|
||||||
"path" => String,
|
|
||||||
"id" => String,
|
|
||||||
"signature" => String,
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
Koa.tags ["admin", "library"]
|
|
||||||
get "/api/admin/entries/missing" do |env|
|
|
||||||
begin
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
"entries" => Storage.default.missing_entries,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Deletes all missing titles"
|
|
||||||
Koa.response 200, schema: "result"
|
|
||||||
Koa.tags ["admin", "library"]
|
|
||||||
delete "/api/admin/titles/missing" do |env|
|
|
||||||
begin
|
|
||||||
Storage.default.delete_missing_title
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Deletes all missing entries"
|
|
||||||
Koa.response 200, schema: "result"
|
|
||||||
Koa.tags ["admin", "library"]
|
|
||||||
delete "/api/admin/entries/missing" do |env|
|
|
||||||
begin
|
|
||||||
Storage.default.delete_missing_entry
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Deletes a missing title identified by `tid`", <<-MD
|
|
||||||
Does nothing if the given `tid` is not found or if the title is not missing.
|
|
||||||
MD
|
|
||||||
Koa.response 200, schema: "result"
|
|
||||||
Koa.tags ["admin", "library"]
|
|
||||||
delete "/api/admin/titles/missing/:tid" do |env|
|
|
||||||
begin
|
|
||||||
tid = env.params.url["tid"]
|
|
||||||
Storage.default.delete_missing_title tid
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Deletes a missing entry identified by `eid`", <<-MD
|
|
||||||
Does nothing if the given `eid` is not found or if the entry is not missing.
|
|
||||||
MD
|
|
||||||
Koa.response 200, schema: "result"
|
|
||||||
Koa.tags ["admin", "library"]
|
|
||||||
delete "/api/admin/entries/missing/:eid" do |env|
|
|
||||||
begin
|
|
||||||
eid = env.params.url["eid"]
|
|
||||||
Storage.default.delete_missing_entry eid
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
struct MainRouter
|
require "./router"
|
||||||
def initialize
|
|
||||||
|
class MainRouter < Router
|
||||||
|
def setup
|
||||||
get "/login" do |env|
|
get "/login" do |env|
|
||||||
base_url = Config.current.base_url
|
render "src/views/login.ecr"
|
||||||
render "src/views/login.html.ecr"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/logout" do |env|
|
get "/logout" do |env|
|
||||||
begin
|
begin
|
||||||
env.session.delete_string "token"
|
cookie = env.request.cookies.find { |c| c.name == "token" }.not_nil!
|
||||||
|
@context.storage.logout cookie.value
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error "Error when attempting to log out: #{e}"
|
@context.error "Error when attempting to log out: #{e}"
|
||||||
ensure
|
ensure
|
||||||
redirect env, "/login"
|
env.redirect "/login"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -19,139 +21,48 @@ struct MainRouter
|
|||||||
begin
|
begin
|
||||||
username = env.params.body["username"]
|
username = env.params.body["username"]
|
||||||
password = env.params.body["password"]
|
password = env.params.body["password"]
|
||||||
token = Storage.default.verify_user(username, password).not_nil!
|
token = @context.storage.verify_user(username, password).not_nil!
|
||||||
|
|
||||||
env.session.string "token", token
|
cookie = HTTP::Cookie.new "token", token
|
||||||
|
cookie.expires = Time.local.shift years: 1
|
||||||
callback = env.session.string? "callback"
|
env.response.cookies << cookie
|
||||||
if callback
|
env.redirect "/"
|
||||||
env.session.delete_string "callback"
|
rescue
|
||||||
redirect env, callback
|
env.redirect "/login"
|
||||||
else
|
|
||||||
redirect env, "/"
|
|
||||||
end
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
redirect env, "/login"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/library" do |env|
|
get "/" do |env|
|
||||||
begin
|
begin
|
||||||
|
titles = @context.library.titles
|
||||||
username = get_username env
|
username = get_username env
|
||||||
|
percentage = titles.map &.load_percetage username
|
||||||
sort_opt = SortOptions.from_info_json Library.default.dir, username
|
use_dotdotdot = !@context.config.disable_ellipsis_truncation
|
||||||
get_and_save_sort_opt Library.default.dir
|
layout "index"
|
||||||
|
|
||||||
titles = Library.default.sorted_titles username, sort_opt
|
|
||||||
percentage = titles.map &.load_percentage username
|
|
||||||
|
|
||||||
layout "library"
|
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
@context.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/book/:title" do |env|
|
get "/book/:title" do |env|
|
||||||
begin
|
begin
|
||||||
title = (Library.default.get_title env.params.url["title"]).not_nil!
|
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
||||||
username = get_username env
|
username = get_username env
|
||||||
|
percentage = title.entries.map { |e|
|
||||||
sort_opt = SortOptions.from_info_json title.dir, username
|
title.load_percetage username, e.title
|
||||||
get_and_save_sort_opt title.dir
|
}
|
||||||
|
use_dotdotdot = !@context.config.disable_ellipsis_truncation
|
||||||
entries = title.sorted_entries username, sort_opt
|
|
||||||
percentage = title.load_percentage_for_all_entries username, sort_opt
|
|
||||||
title_percentage = title.titles.map &.load_percentage username
|
|
||||||
|
|
||||||
layout "title"
|
layout "title"
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
@context.error e
|
||||||
env.response.status_code = 500
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/download/plugins" do |env|
|
|
||||||
begin
|
|
||||||
id = env.params.query["plugin"]?
|
|
||||||
plugins = Plugin.list
|
|
||||||
plugin = nil
|
|
||||||
|
|
||||||
if id
|
|
||||||
plugin = Plugin.new id
|
|
||||||
elsif !plugins.empty?
|
|
||||||
plugin = Plugin.new plugins[0][:id]
|
|
||||||
end
|
|
||||||
|
|
||||||
layout "plugin-download"
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
env.response.status_code = 500
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/" do |env|
|
|
||||||
begin
|
|
||||||
username = get_username env
|
|
||||||
continue_reading = Library.default
|
|
||||||
.get_continue_reading_entries username
|
|
||||||
recently_added = Library.default.get_recently_added_entries username
|
|
||||||
start_reading = Library.default.get_start_reading_titles username
|
|
||||||
titles = Library.default.titles
|
|
||||||
new_user = !titles.any? &.load_percentage(username).> 0
|
|
||||||
empty_library = titles.size == 0
|
|
||||||
layout "home"
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
env.response.status_code = 500
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/tags/:tag" do |env|
|
|
||||||
begin
|
|
||||||
username = get_username env
|
|
||||||
tag = env.params.url["tag"]
|
|
||||||
|
|
||||||
sort_opt = SortOptions.new
|
|
||||||
get_sort_opt
|
|
||||||
|
|
||||||
title_ids = Storage.default.get_tag_titles tag
|
|
||||||
|
|
||||||
raise "Tag #{tag} not found" if title_ids.empty?
|
|
||||||
|
|
||||||
titles = title_ids.map { |id| Library.default.get_title id }
|
|
||||||
.select Title
|
|
||||||
|
|
||||||
titles = sort_titles titles, sort_opt, username
|
|
||||||
percentage = titles.map &.load_percentage username
|
|
||||||
|
|
||||||
layout "tag"
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/tags" do |env|
|
get "/download" do |env|
|
||||||
tags = Storage.default.list_tags.map do |tag|
|
base_url = @context.config.mangadex["base_url"]
|
||||||
{
|
layout "download"
|
||||||
tag: tag,
|
|
||||||
encoded_tag: URI.encode_www_form(tag, space_to_plus: false),
|
|
||||||
count: Storage.default.get_tag_titles(tag).size,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
# Sort by :count reversly, and then sort by :tag
|
|
||||||
tags.sort! do |a, b|
|
|
||||||
(b[:count] <=> a[:count]).or(a[:tag] <=> b[:tag])
|
|
||||||
end
|
|
||||||
|
|
||||||
layout "tags"
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/api" do |env|
|
|
||||||
base_url = Config.current.base_url
|
|
||||||
render "src/views/api.html.ecr"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
struct OPDSRouter
|
|
||||||
def initialize
|
|
||||||
get "/opds" do |env|
|
|
||||||
titles = Library.default.titles
|
|
||||||
render_xml "src/views/opds/index.xml.ecr"
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/opds/book/:title_id" do |env|
|
|
||||||
begin
|
|
||||||
title = Library.default.get_title(env.params.url["title_id"]).not_nil!
|
|
||||||
render_xml "src/views/opds/title.xml.ecr"
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
env.response.status_code = 404
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,58 +1,59 @@
|
|||||||
struct ReaderRouter
|
require "./router"
|
||||||
def initialize
|
|
||||||
|
class ReaderRouter < Router
|
||||||
|
def setup
|
||||||
get "/reader/:title/:entry" do |env|
|
get "/reader/:title/:entry" do |env|
|
||||||
begin
|
begin
|
||||||
username = get_username env
|
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
||||||
|
|
||||||
title = (Library.default.get_title env.params.url["title"]).not_nil!
|
|
||||||
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
||||||
|
|
||||||
next layout "reader-error" if entry.err_msg
|
|
||||||
|
|
||||||
# load progress
|
# load progress
|
||||||
page_idx = [1, entry.load_progress username].max
|
username = get_username env
|
||||||
|
page = title.load_progress username, entry.title
|
||||||
|
# we go back 2 * `IMGS_PER_PAGE` pages. the infinite scroll
|
||||||
|
# library perloads a few pages in advance, and the user
|
||||||
|
# might not have actually read them
|
||||||
|
page = [page - 2 * IMGS_PER_PAGE, 1].max
|
||||||
|
|
||||||
# start from page 1 if the user has finished reading the entry
|
env.redirect "/reader/#{title.id}/#{entry.id}/#{page}"
|
||||||
page_idx = 1 if entry.finished? username
|
|
||||||
|
|
||||||
redirect env, "/reader/#{title.id}/#{entry.id}/#{page_idx}"
|
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
@context.error e
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/reader/:title/:entry/:page" do |env|
|
get "/reader/:title/:entry/:page" do |env|
|
||||||
begin
|
begin
|
||||||
base_url = Config.current.base_url
|
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
||||||
|
|
||||||
username = get_username env
|
|
||||||
|
|
||||||
title = (Library.default.get_title env.params.url["title"]).not_nil!
|
|
||||||
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
||||||
|
page = env.params.url["page"].to_i
|
||||||
|
raise "" if page > entry.pages || page <= 0
|
||||||
|
|
||||||
sort_opt = SortOptions.from_info_json title.dir, username
|
# save progress
|
||||||
get_sort_opt
|
username = get_username env
|
||||||
entries = title.sorted_entries username, sort_opt
|
title.save_progress username, entry.title, page
|
||||||
|
|
||||||
page_idx = env.params.url["page"].to_i
|
pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min)
|
||||||
if page_idx > entry.pages || page_idx <= 0
|
urls = pages.map { |idx|
|
||||||
raise "Page #{page_idx} not found."
|
"/api/page/#{title.id}/#{entry.id}/#{idx}"
|
||||||
|
}
|
||||||
|
reader_urls = pages.map { |idx|
|
||||||
|
"/reader/#{title.id}/#{entry.id}/#{idx}"
|
||||||
|
}
|
||||||
|
next_page = page + IMGS_PER_PAGE
|
||||||
|
next_url = next_entry_url = nil
|
||||||
|
exit_url = "/book/#{title.id}"
|
||||||
|
next_entry = title.next_entry entry
|
||||||
|
unless next_page > entry.pages
|
||||||
|
next_url = "/reader/#{title.id}/#{entry.id}/#{next_page}"
|
||||||
|
end
|
||||||
|
unless next_entry.nil?
|
||||||
|
next_entry_url = "/reader/#{title.id}/#{next_entry.id}"
|
||||||
end
|
end
|
||||||
|
|
||||||
exit_url = "#{base_url}book/#{title.id}"
|
render "src/views/reader.ecr"
|
||||||
|
|
||||||
next_entry_url = entry.next_entry(username).try do |e|
|
|
||||||
"#{base_url}reader/#{title.id}/#{e.id}"
|
|
||||||
end
|
|
||||||
|
|
||||||
previous_entry_url = entry.previous_entry(username).try do |e|
|
|
||||||
"#{base_url}reader/#{title.id}/#{e.id}"
|
|
||||||
end
|
|
||||||
|
|
||||||
render "src/views/reader.html.ecr"
|
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
@context.error e
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
6
src/routes/router.cr
Normal file
6
src/routes/router.cr
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
require "../context"
|
||||||
|
|
||||||
|
class Router
|
||||||
|
def initialize(@context : Context)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,56 +1,47 @@
|
|||||||
require "kemal"
|
require "kemal"
|
||||||
require "kemal-session"
|
require "./context"
|
||||||
require "./library/*"
|
|
||||||
require "./handlers/*"
|
require "./handlers/*"
|
||||||
require "./util/*"
|
require "./util"
|
||||||
require "./routes/*"
|
require "./routes/*"
|
||||||
|
|
||||||
class Server
|
class Server
|
||||||
def initialize
|
def initialize(@context : Context)
|
||||||
|
error 403 do |env|
|
||||||
|
message = "HTTP 403: You are not authorized to visit #{env.request.path}"
|
||||||
|
layout "message"
|
||||||
|
end
|
||||||
error 404 do |env|
|
error 404 do |env|
|
||||||
message = "HTTP 404: Mango cannot find the page #{env.request.path}"
|
message = "HTTP 404: Mango cannot find the page #{env.request.path}"
|
||||||
layout "message"
|
layout "message"
|
||||||
end
|
end
|
||||||
|
|
||||||
{% if flag?(:release) %}
|
|
||||||
error 500 do |env|
|
error 500 do |env|
|
||||||
message = "HTTP 500: Internal server error. Please try again later."
|
message = "HTTP 500: Internal server error. Please try again later."
|
||||||
layout "message"
|
layout "message"
|
||||||
end
|
end
|
||||||
{% end %}
|
|
||||||
|
|
||||||
MainRouter.new
|
MainRouter.new(@context).setup
|
||||||
AdminRouter.new
|
AdminRouter.new(@context).setup
|
||||||
ReaderRouter.new
|
ReaderRouter.new(@context).setup
|
||||||
APIRouter.new
|
APIRouter.new(@context).setup
|
||||||
OPDSRouter.new
|
|
||||||
|
|
||||||
Kemal.config.logging = false
|
Kemal.config.logging = false
|
||||||
add_handler LogHandler.new
|
add_handler LogHandler.new @context.logger
|
||||||
add_handler AuthHandler.new
|
add_handler AuthHandler.new @context.storage
|
||||||
add_handler UploadHandler.new Config.current.upload_path
|
add_handler UploadHandler.new @context.config.upload_path
|
||||||
{% if flag?(:release) %}
|
{% if flag?(:release) %}
|
||||||
# when building for relase, embed the static files in binary
|
# when building for relase, embed the static files in binary
|
||||||
Logger.debug "We are in release mode. Using embedded static files."
|
@context.debug "We are in release mode. Using embedded static files."
|
||||||
serve_static false
|
serve_static false
|
||||||
add_handler StaticHandler.new
|
add_handler StaticHandler.new
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|
||||||
Kemal::Session.config do |c|
|
|
||||||
c.timeout = 365.days
|
|
||||||
c.secret = Config.current.session_secret
|
|
||||||
c.cookie_name = "mango-sessid-#{Config.current.port}"
|
|
||||||
c.path = Config.current.base_url
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def start
|
def start
|
||||||
Logger.debug "Starting Kemal server"
|
@context.debug "Starting Kemal server"
|
||||||
{% if flag?(:release) %}
|
{% if flag?(:release) %}
|
||||||
Kemal.config.env = "production"
|
Kemal.config.env = "production"
|
||||||
{% end %}
|
{% end %}
|
||||||
Kemal.config.host_binding = Config.current.host
|
Kemal.config.port = @context.config.port
|
||||||
Kemal.config.port = Config.current.port
|
|
||||||
Kemal.run
|
Kemal.run
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
500
src/storage.cr
500
src/storage.cr
@@ -2,9 +2,7 @@ require "sqlite3"
|
|||||||
require "crypto/bcrypt"
|
require "crypto/bcrypt"
|
||||||
require "uuid"
|
require "uuid"
|
||||||
require "base64"
|
require "base64"
|
||||||
require "./util/*"
|
require "./util"
|
||||||
require "mg"
|
|
||||||
require "../migration/*"
|
|
||||||
|
|
||||||
def hash_password(pw)
|
def hash_password(pw)
|
||||||
Crypto::Bcrypt::Password.create(pw).to_s
|
Crypto::Bcrypt::Password.create(pw).to_s
|
||||||
@@ -15,129 +13,76 @@ def verify_password(hash, pw)
|
|||||||
end
|
end
|
||||||
|
|
||||||
class Storage
|
class Storage
|
||||||
@@insert_entry_ids = [] of IDTuple
|
def initialize(@path : String, @logger : Logger)
|
||||||
@@insert_title_ids = [] of IDTuple
|
dir = File.dirname path
|
||||||
|
|
||||||
@path : String
|
|
||||||
@db : DB::Database?
|
|
||||||
|
|
||||||
alias IDTuple = NamedTuple(
|
|
||||||
path: String,
|
|
||||||
id: String,
|
|
||||||
signature: String?)
|
|
||||||
|
|
||||||
use_default
|
|
||||||
|
|
||||||
def initialize(db_path : String? = nil, init_user = true, *,
|
|
||||||
@auto_close = true)
|
|
||||||
@path = db_path || Config.current.db_path
|
|
||||||
dir = File.dirname @path
|
|
||||||
unless Dir.exists? dir
|
unless Dir.exists? dir
|
||||||
Logger.info "The DB directory #{dir} does not exist. " \
|
@logger.info "The DB directory #{dir} does not exist. " \
|
||||||
"Attempting to create it"
|
"Attepmting to create it"
|
||||||
Dir.mkdir_p dir
|
Dir.mkdir_p dir
|
||||||
end
|
end
|
||||||
MainFiber.run do
|
DB.open "sqlite3://#{path}" do |db|
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
|
||||||
begin
|
begin
|
||||||
MG::Migration.new(db, log: Logger.default.raw_log).migrate
|
# We create the `ids` table first. even if the uses has an
|
||||||
|
# early version installed and has the `user` table only,
|
||||||
|
# we will still be able to create `ids`
|
||||||
|
db.exec "create table ids" \
|
||||||
|
"(path text, id text, is_title integer)"
|
||||||
|
db.exec "create unique index path_idx on ids (path)"
|
||||||
|
db.exec "create unique index id_idx on ids (id)"
|
||||||
|
|
||||||
|
db.exec "create table users" \
|
||||||
|
"(username text, password text, token text, admin integer)"
|
||||||
rescue e
|
rescue e
|
||||||
Logger.fatal "DB migration failed. #{e}"
|
unless e.message.not_nil!.ends_with? "already exists"
|
||||||
|
@logger.fatal "Error when checking tables in DB: #{e}"
|
||||||
raise e
|
raise e
|
||||||
end
|
end
|
||||||
|
else
|
||||||
user_count = db.query_one "select count(*) from users", as: Int32
|
@logger.debug "Creating DB file at #{@path}"
|
||||||
init_admin if init_user && user_count == 0
|
db.exec "create unique index username_idx on users (username)"
|
||||||
end
|
db.exec "create unique index token_idx on users (token)"
|
||||||
unless @auto_close
|
|
||||||
@db = DB.open "sqlite3://#{@path}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
macro init_admin
|
|
||||||
random_pw = random_str
|
random_pw = random_str
|
||||||
hash = hash_password random_pw
|
hash = hash_password random_pw
|
||||||
db.exec "insert into users values (?, ?, ?, ?)",
|
db.exec "insert into users values (?, ?, ?, ?)",
|
||||||
"admin", hash, nil, 1
|
"admin", hash, nil, 1
|
||||||
Logger.log "Initial user created. You can log in with " \
|
@logger.log "Initial user created. You can log in with " \
|
||||||
"#{{"username" => "admin", "password" => random_pw}}"
|
"#{{"username" => "admin", "password" => random_pw}}"
|
||||||
end
|
end
|
||||||
|
|
||||||
private def get_db(&block : DB::Database ->)
|
|
||||||
if @db.nil?
|
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
|
||||||
db.exec "PRAGMA foreign_keys = 1"
|
|
||||||
yield db
|
|
||||||
end
|
end
|
||||||
else
|
|
||||||
@db.not_nil!.exec "PRAGMA foreign_keys = 1"
|
|
||||||
yield @db.not_nil!
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def username_exists(username)
|
|
||||||
exists = false
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
exists = db.query_one("select count(*) from users where " \
|
|
||||||
"username = (?)", username, as: Int32) > 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
exists
|
|
||||||
end
|
|
||||||
|
|
||||||
def username_is_admin(username)
|
|
||||||
is_admin = false
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
is_admin = db.query_one("select admin from users where " \
|
|
||||||
"username = (?)", username, as: Int32) > 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
is_admin
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify_user(username, password)
|
def verify_user(username, password)
|
||||||
out_token = nil
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
begin
|
begin
|
||||||
hash, token = db.query_one "select password, token from " \
|
hash, token = db.query_one "select password, token from " \
|
||||||
"users where username = (?)",
|
"users where username = (?)",
|
||||||
username, as: {String, String?}
|
username, as: {String, String?}
|
||||||
unless verify_password hash, password
|
unless verify_password hash, password
|
||||||
Logger.debug "Password does not match the hash"
|
@logger.debug "Password does not match the hash"
|
||||||
next
|
return nil
|
||||||
end
|
|
||||||
Logger.debug "User #{username} verified"
|
|
||||||
if token
|
|
||||||
out_token = token
|
|
||||||
next
|
|
||||||
end
|
end
|
||||||
|
@logger.debug "User #{username} verified"
|
||||||
|
return token if token
|
||||||
token = random_str
|
token = random_str
|
||||||
Logger.debug "Updating token for #{username}"
|
@logger.debug "Updating token for #{username}"
|
||||||
db.exec "update users set token = (?) where username = (?)",
|
db.exec "update users set token = (?) where username = (?)",
|
||||||
token, username
|
token, username
|
||||||
out_token = token
|
return token
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error "Error when verifying user #{username}: #{e}"
|
@logger.error "Error when verifying user #{username}: #{e}"
|
||||||
|
return nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
out_token
|
|
||||||
end
|
|
||||||
|
|
||||||
def verify_token(token)
|
def verify_token(token)
|
||||||
username = nil
|
username = nil
|
||||||
MainFiber.run do
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
get_db do |db|
|
|
||||||
begin
|
begin
|
||||||
username = db.query_one "select username from users where " \
|
username = db.query_one "select username from users where " \
|
||||||
"token = (?)", token, as: String
|
"token = (?)", token, as: String
|
||||||
rescue e
|
rescue e
|
||||||
Logger.debug "Unable to verify token"
|
@logger.debug "Unable to verify token"
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
username
|
username
|
||||||
@@ -145,14 +90,12 @@ class Storage
|
|||||||
|
|
||||||
def verify_admin(token)
|
def verify_admin(token)
|
||||||
is_admin = false
|
is_admin = false
|
||||||
MainFiber.run do
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
get_db do |db|
|
|
||||||
begin
|
begin
|
||||||
is_admin = db.query_one "select admin from users where " \
|
is_admin = db.query_one "select admin from users where " \
|
||||||
"token = (?)", token, as: Bool
|
"token = (?)", token, as: Bool
|
||||||
rescue e
|
rescue e
|
||||||
Logger.debug "Unable to verify user as admin"
|
@logger.debug "Unable to verify user as admin"
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
is_admin
|
is_admin
|
||||||
@@ -160,38 +103,29 @@ class Storage
|
|||||||
|
|
||||||
def list_users
|
def list_users
|
||||||
results = Array(Tuple(String, Bool)).new
|
results = Array(Tuple(String, Bool)).new
|
||||||
MainFiber.run do
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
get_db do |db|
|
|
||||||
db.query "select username, admin from users" do |rs|
|
db.query "select username, admin from users" do |rs|
|
||||||
rs.each do
|
rs.each do
|
||||||
results << {rs.read(String), rs.read(Bool)}
|
results << {rs.read(String), rs.read(Bool)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
results
|
results
|
||||||
end
|
end
|
||||||
|
|
||||||
def new_user(username, password, admin)
|
def new_user(username, password, admin)
|
||||||
validate_username username
|
|
||||||
validate_password password
|
|
||||||
admin = (admin ? 1 : 0)
|
admin = (admin ? 1 : 0)
|
||||||
MainFiber.run do
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
get_db do |db|
|
|
||||||
hash = hash_password password
|
hash = hash_password password
|
||||||
db.exec "insert into users values (?, ?, ?, ?)",
|
db.exec "insert into users values (?, ?, ?, ?)",
|
||||||
username, hash, nil, admin
|
username, hash, nil, admin
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
def update_user(original_username, username, password, admin)
|
def update_user(original_username, username, password, admin)
|
||||||
admin = (admin ? 1 : 0)
|
admin = (admin ? 1 : 0)
|
||||||
validate_username username
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
validate_password password unless password.empty?
|
if password.size == 0
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
if password.empty?
|
|
||||||
db.exec "update users set username = (?), admin = (?) " \
|
db.exec "update users set username = (?), admin = (?) " \
|
||||||
"where username = (?)",
|
"where username = (?)",
|
||||||
username, admin, original_username
|
username, admin, original_username
|
||||||
@@ -203,367 +137,33 @@ class Storage
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
def delete_user(username)
|
def delete_user(username)
|
||||||
MainFiber.run do
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
get_db do |db|
|
|
||||||
db.exec "delete from users where username = (?)", username
|
db.exec "delete from users where username = (?)", username
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
def logout(token)
|
def logout(token)
|
||||||
MainFiber.run do
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
get_db do |db|
|
|
||||||
begin
|
begin
|
||||||
db.exec "update users set token = (?) where token = (?)", nil, token
|
db.exec "update users set token = (?) where token = (?)", nil, token
|
||||||
rescue
|
rescue
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
def get_title_id(path, signature)
|
def get_id(path, is_title)
|
||||||
id = nil
|
id = random_str
|
||||||
path = Path.new(path).relative_to(Config.current.library_path).to_s
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
# First attempt to find the matching title in DB using BOTH path
|
|
||||||
# and signature
|
|
||||||
id = db.query_one? "select id from titles where path = (?) and " \
|
|
||||||
"signature = (?) and unavailable = 0",
|
|
||||||
path, signature.to_s, as: String
|
|
||||||
|
|
||||||
should_update = id.nil?
|
|
||||||
# If it fails, try to match using the path only. This could happen
|
|
||||||
# for example when a new entry is added to the title
|
|
||||||
id ||= db.query_one? "select id from titles where path = (?)", path,
|
|
||||||
as: String
|
|
||||||
|
|
||||||
# If it still fails, we will have to rely on the signature values.
|
|
||||||
# This could happen when the user moved or renamed the title, or
|
|
||||||
# a title containing the title
|
|
||||||
unless id
|
|
||||||
# If there are multiple rows with the same signature (this could
|
|
||||||
# happen simply by bad luck, or when the user copied a title),
|
|
||||||
# pick the row that has the most similar path to the give path
|
|
||||||
rows = [] of Tuple(String, String)
|
|
||||||
db.query "select id, path from titles where signature = (?)",
|
|
||||||
signature.to_s do |rs|
|
|
||||||
rs.each do
|
|
||||||
rows << {rs.read(String), rs.read(String)}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
row = rows.max_by?(&.[1].components_similarity(path))
|
|
||||||
id = row[0] if row
|
|
||||||
end
|
|
||||||
|
|
||||||
# At this point, `id` would still be nil if there's no row matching
|
|
||||||
# either the path or the signature
|
|
||||||
|
|
||||||
# If we did identify a matching title, save the path and signature
|
|
||||||
# values back to the DB
|
|
||||||
if id && should_update
|
|
||||||
db.exec "update titles set path = (?), signature = (?), " \
|
|
||||||
"unavailable = 0 where id = (?)", path, signature.to_s, id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
id
|
|
||||||
end
|
|
||||||
|
|
||||||
# See the comments in `#get_title_id` to see how this method works.
|
|
||||||
def get_entry_id(path, signature)
|
|
||||||
id = nil
|
|
||||||
path = Path.new(path).relative_to(Config.current.library_path).to_s
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
id = db.query_one? "select id from ids where path = (?) and " \
|
|
||||||
"signature = (?) and unavailable = 0",
|
|
||||||
path, signature.to_s, as: String
|
|
||||||
|
|
||||||
should_update = id.nil?
|
|
||||||
id ||= db.query_one? "select id from ids where path = (?)", path,
|
|
||||||
as: String
|
|
||||||
|
|
||||||
unless id
|
|
||||||
rows = [] of Tuple(String, String)
|
|
||||||
db.query "select id, path from ids where signature = (?)",
|
|
||||||
signature.to_s do |rs|
|
|
||||||
rs.each do
|
|
||||||
rows << {rs.read(String), rs.read(String)}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
row = rows.max_by?(&.[1].components_similarity(path))
|
|
||||||
id = row[0] if row
|
|
||||||
end
|
|
||||||
|
|
||||||
if id && should_update
|
|
||||||
db.exec "update ids set path = (?), signature = (?), " \
|
|
||||||
"unavailable = 0 where id = (?)", path, signature.to_s, id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
id
|
|
||||||
end
|
|
||||||
|
|
||||||
def insert_entry_id(tp)
|
|
||||||
@@insert_entry_ids << tp
|
|
||||||
end
|
|
||||||
|
|
||||||
def insert_title_id(tp)
|
|
||||||
@@insert_title_ids << tp
|
|
||||||
end
|
|
||||||
|
|
||||||
def bulk_insert_ids
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
db.transaction do |tran|
|
|
||||||
conn = tran.connection
|
|
||||||
@@insert_title_ids.each do |tp|
|
|
||||||
path = Path.new(tp[:path])
|
|
||||||
.relative_to(Config.current.library_path).to_s
|
|
||||||
conn.exec "insert into titles (id, path, signature, " \
|
|
||||||
"unavailable) values (?, ?, ?, 0)",
|
|
||||||
tp[:id], path, tp[:signature].to_s
|
|
||||||
end
|
|
||||||
@@insert_entry_ids.each do |tp|
|
|
||||||
path = Path.new(tp[:path])
|
|
||||||
.relative_to(Config.current.library_path).to_s
|
|
||||||
conn.exec "insert into ids (id, path, signature, " \
|
|
||||||
"unavailable) values (?, ?, ?, 0)",
|
|
||||||
tp[:id], path, tp[:signature].to_s
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@insert_entry_ids.clear
|
|
||||||
@@insert_title_ids.clear
|
|
||||||
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 get_title_tags(id : String) : Array(String)
|
|
||||||
tags = [] of String
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
db.query "select tag from tags where id = (?) order by tag", id do |rs|
|
|
||||||
rs.each do
|
|
||||||
tags << rs.read String
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
tags
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_tag_titles(tag : String) : Array(String)
|
|
||||||
tids = [] of String
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
db.query "select id from tags where tag = (?)", tag do |rs|
|
|
||||||
rs.each do
|
|
||||||
tids << rs.read String
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
tids
|
|
||||||
end
|
|
||||||
|
|
||||||
def list_tags : Array(String)
|
|
||||||
tags = [] of String
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
db.query "select distinct tag from tags natural join titles " \
|
|
||||||
"where unavailable = 0" do |rs|
|
|
||||||
rs.each do
|
|
||||||
tags << rs.read String
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
tags
|
|
||||||
end
|
|
||||||
|
|
||||||
def add_tag(id : String, tag : String)
|
|
||||||
err = nil
|
|
||||||
MainFiber.run do
|
|
||||||
begin
|
begin
|
||||||
get_db do |db|
|
id = db.query_one "select id from ids where path = (?)", path,
|
||||||
db.exec "insert into tags values (?, ?)", id, tag
|
as: {String}
|
||||||
end
|
rescue
|
||||||
rescue e
|
db.exec "insert into ids values (?, ?, ?)", path, id, is_title ? 1 : 0
|
||||||
err = e
|
|
||||||
end
|
|
||||||
end
|
|
||||||
raise err.not_nil! if err
|
|
||||||
end
|
|
||||||
|
|
||||||
def delete_tag(id : String, tag : String)
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
db.exec "delete from tags where id = (?) and tag = (?)", id, tag
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Mark titles and entries that no longer exist on the file system as
|
|
||||||
# unavailable. By supplying `id_candidates` and `titles_candidates`, it
|
|
||||||
# only checks the existence of the candidate titles/entries to speed up
|
|
||||||
# the process.
|
|
||||||
def mark_unavailable(ids_candidates : Array(String)?,
|
|
||||||
titles_candidates : Array(String)?)
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
# Detect dangling entry IDs
|
|
||||||
trash_ids = [] of String
|
|
||||||
query = "select path, id from ids where unavailable = 0"
|
|
||||||
unless ids_candidates.nil?
|
|
||||||
query += " and id in (#{ids_candidates.join "," { |i| "'#{i}'" }})"
|
|
||||||
end
|
|
||||||
db.query query do |rs|
|
|
||||||
rs.each do
|
|
||||||
path = rs.read String
|
|
||||||
fullpath = Path.new(path).expand(Config.current.library_path).to_s
|
|
||||||
trash_ids << rs.read String unless File.exists? fullpath
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
unless trash_ids.empty?
|
|
||||||
Logger.debug "Marking #{trash_ids.size} entries as unavailable"
|
|
||||||
end
|
|
||||||
db.exec "update ids set unavailable = 1 where id in " \
|
|
||||||
"(#{trash_ids.join "," { |i| "'#{i}'" }})"
|
|
||||||
|
|
||||||
# Detect dangling title IDs
|
|
||||||
trash_titles = [] of String
|
|
||||||
query = "select path, id from titles where unavailable = 0"
|
|
||||||
unless titles_candidates.nil?
|
|
||||||
query += " and id in (#{titles_candidates.join "," { |i| "'#{i}'" }})"
|
|
||||||
end
|
|
||||||
db.query query do |rs|
|
|
||||||
rs.each do
|
|
||||||
path = rs.read String
|
|
||||||
fullpath = Path.new(path).expand(Config.current.library_path).to_s
|
|
||||||
trash_titles << rs.read String unless Dir.exists? fullpath
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
unless trash_titles.empty?
|
|
||||||
Logger.debug "Marking #{trash_titles.size} titles as unavailable"
|
|
||||||
end
|
|
||||||
db.exec "update titles set unavailable = 1 where id in " \
|
|
||||||
"(#{trash_titles.join "," { |i| "'#{i}'" }})"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private def get_missing(tablename)
|
|
||||||
ary = [] of IDTuple
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
db.query "select id, path, signature from #{tablename} " \
|
|
||||||
"where unavailable = 1" do |rs|
|
|
||||||
rs.each do
|
|
||||||
ary << {
|
|
||||||
id: rs.read(String),
|
|
||||||
path: rs.read(String),
|
|
||||||
signature: rs.read(String?),
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
ary
|
|
||||||
end
|
|
||||||
|
|
||||||
private def delete_missing(tablename, id : String? = nil)
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
if id
|
|
||||||
db.exec "delete from #{tablename} where id = (?) " \
|
|
||||||
"and unavailable = 1", id
|
|
||||||
else
|
|
||||||
db.exec "delete from #{tablename} where unavailable = 1"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def missing_entries
|
|
||||||
get_missing "ids"
|
|
||||||
end
|
|
||||||
|
|
||||||
def missing_titles
|
|
||||||
get_missing "titles"
|
|
||||||
end
|
|
||||||
|
|
||||||
def delete_missing_entry(id = nil)
|
|
||||||
delete_missing "ids", id
|
|
||||||
end
|
|
||||||
|
|
||||||
def delete_missing_title(id = nil)
|
|
||||||
delete_missing "titles", id
|
|
||||||
end
|
|
||||||
|
|
||||||
def save_md_token(username : String, token : String, expire : Time)
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
count = db.query_one "select count(*) from md_account where " \
|
|
||||||
"username = (?)", username, as: Int64
|
|
||||||
if count == 0
|
|
||||||
db.exec "insert into md_account values (?, ?, ?)", username, token,
|
|
||||||
expire.to_unix
|
|
||||||
else
|
|
||||||
db.exec "update md_account set token = (?), expire = (?) " \
|
|
||||||
"where username = (?)", token, expire.to_unix, username
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_md_token(username) : Tuple(String?, Time?)
|
|
||||||
token = nil
|
|
||||||
expires = nil
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
db.query_one? "select token, expire from md_account where " \
|
|
||||||
"username = (?)", username do |res|
|
|
||||||
token = res.read String
|
|
||||||
expires = Time.unix res.read Int64
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
{token, expires}
|
|
||||||
end
|
|
||||||
|
|
||||||
def close
|
|
||||||
MainFiber.run do
|
|
||||||
unless @db.nil?
|
|
||||||
@db.not_nil!.close
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
id
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
def to_json(json : JSON::Builder)
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
require "db"
|
|
||||||
require "json"
|
|
||||||
|
|
||||||
struct Subscription
|
|
||||||
include DB::Serializable
|
|
||||||
include JSON::Serializable
|
|
||||||
|
|
||||||
getter id : Int64 = 0
|
|
||||||
getter username : String
|
|
||||||
getter manga_id : Int64
|
|
||||||
property language : String?
|
|
||||||
property group_id : Int64?
|
|
||||||
property min_volume : Int64?
|
|
||||||
property max_volume : Int64?
|
|
||||||
property min_chapter : Int64?
|
|
||||||
property max_chapter : Int64?
|
|
||||||
@[DB::Field(key: "last_checked")]
|
|
||||||
@[JSON::Field(key: "last_checked")]
|
|
||||||
@raw_last_checked : Int64
|
|
||||||
@[DB::Field(key: "created_at")]
|
|
||||||
@[JSON::Field(key: "created_at")]
|
|
||||||
@raw_created_at : Int64
|
|
||||||
|
|
||||||
def last_checked : Time
|
|
||||||
Time.unix @raw_last_checked
|
|
||||||
end
|
|
||||||
|
|
||||||
def created_at : Time
|
|
||||||
Time.unix @raw_created_at
|
|
||||||
end
|
|
||||||
|
|
||||||
def initialize(@manga_id, @username)
|
|
||||||
@raw_created_at = Time.utc.to_unix
|
|
||||||
@raw_last_checked = Time.utc.to_unix
|
|
||||||
end
|
|
||||||
|
|
||||||
private def in_range?(value : String, lowerbound : Int64?,
|
|
||||||
upperbound : Int64?) : Bool
|
|
||||||
lb = lowerbound.try &.to_f64
|
|
||||||
ub = upperbound.try &.to_f64
|
|
||||||
|
|
||||||
return true if lb.nil? && ub.nil?
|
|
||||||
|
|
||||||
v = value.to_f64?
|
|
||||||
return false unless v
|
|
||||||
|
|
||||||
if lb.nil?
|
|
||||||
v <= ub.not_nil!
|
|
||||||
elsif ub.nil?
|
|
||||||
v >= lb.not_nil!
|
|
||||||
else
|
|
||||||
v >= lb.not_nil! && v <= ub.not_nil!
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def match?(chapter : MangaDex::Chapter) : Bool
|
|
||||||
if chapter.manga_id != manga_id ||
|
|
||||||
(language && chapter.language != language) ||
|
|
||||||
(group_id && !chapter.groups.map(&.id).includes? group_id)
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
in_range?(chapter.volume, min_volume, max_volume) &&
|
|
||||||
in_range?(chapter.chapter, min_chapter, max_chapter)
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_for_updates : Int32
|
|
||||||
Logger.debug "Checking updates for subscription with ID #{id}"
|
|
||||||
jobs = [] of Queue::Job
|
|
||||||
get_client(username).user.updates_after last_checked do |chapter|
|
|
||||||
next unless match? chapter
|
|
||||||
jobs << chapter.to_job
|
|
||||||
end
|
|
||||||
Storage.default.update_subscription_last_checked id
|
|
||||||
count = Queue.default.push jobs
|
|
||||||
Logger.debug "#{count}/#{jobs.size} of updates added to queue"
|
|
||||||
count
|
|
||||||
rescue e
|
|
||||||
Logger.error "Error occurred when checking updates for " \
|
|
||||||
"subscription with ID #{id}. #{e}"
|
|
||||||
0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
require "./util/*"
|
require "./util"
|
||||||
|
|
||||||
class Upload
|
class Upload
|
||||||
def initialize(@dir : String)
|
def initialize(@dir : String, @logger : Logger)
|
||||||
unless Dir.exists? @dir
|
unless Dir.exists? @dir
|
||||||
Logger.info "The uploads directory #{@dir} does not exist. " \
|
@logger.info "The uploads directory #{@dir} does not exist. " \
|
||||||
"Attempting to create it"
|
"Attempting to create it"
|
||||||
Dir.mkdir_p @dir
|
Dir.mkdir_p @dir
|
||||||
end
|
end
|
||||||
@@ -19,7 +19,7 @@ class Upload
|
|||||||
file_path = File.join full_dir, filename
|
file_path = File.join full_dir, filename
|
||||||
|
|
||||||
unless Dir.exists? full_dir
|
unless Dir.exists? full_dir
|
||||||
Logger.debug "creating directory #{full_dir}"
|
@logger.debug "creating directory #{full_dir}"
|
||||||
Dir.mkdir_p full_dir
|
Dir.mkdir_p full_dir
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ class Upload
|
|||||||
end
|
end
|
||||||
|
|
||||||
if ary.empty?
|
if ary.empty?
|
||||||
Logger.warn "File #{path} is not in the upload directory #{@dir}"
|
@logger.warn "File #{path} is not in the upload directory #{@dir}"
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
101
src/util.cr
Normal file
101
src/util.cr
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
require "big"
|
||||||
|
|
||||||
|
IMGS_PER_PAGE = 5
|
||||||
|
UPLOAD_URL_PREFIX = "/uploads"
|
||||||
|
|
||||||
|
macro layout(name)
|
||||||
|
begin
|
||||||
|
cookie = env.request.cookies.find { |c| c.name == "token" }
|
||||||
|
is_admin = false
|
||||||
|
unless cookie.nil?
|
||||||
|
is_admin = @context.storage.verify_admin cookie.value
|
||||||
|
end
|
||||||
|
render "src/views/#{{{name}}}.ecr", "src/views/layout.ecr"
|
||||||
|
rescue e
|
||||||
|
message = e.to_s
|
||||||
|
@context.error message
|
||||||
|
render "src/views/message.ecr", "src/views/layout.ecr"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
macro send_img(env, img)
|
||||||
|
send_file {{env}}, {{img}}.data, {{img}}.mime
|
||||||
|
end
|
||||||
|
|
||||||
|
macro get_username(env)
|
||||||
|
# if the request gets here, it has gone through the auth handler, and
|
||||||
|
# we can be sure that a valid token exists, so we can use not_nil! here
|
||||||
|
cookie = {{env}}.request.cookies.find { |c| c.name == "token" }.not_nil!
|
||||||
|
(@context.storage.verify_token cookie.value).not_nil!
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_json(env, json)
|
||||||
|
env.response.content_type = "application/json"
|
||||||
|
env.response.print json
|
||||||
|
end
|
||||||
|
|
||||||
|
def hash_to_query(hash)
|
||||||
|
hash.map { |k, v| "#{k}=#{v}" }.join("&")
|
||||||
|
end
|
||||||
|
|
||||||
|
def request_path_startswith(env, ary)
|
||||||
|
ary.each do |prefix|
|
||||||
|
if env.request.path.starts_with? prefix
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_numeric(str)
|
||||||
|
/^\d+/.match(str) != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def split_by_alphanumeric(str)
|
||||||
|
arr = [] of String
|
||||||
|
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
|
||||||
|
arr += match.captures.select { |s| s != "" }
|
||||||
|
end
|
||||||
|
arr
|
||||||
|
end
|
||||||
|
|
||||||
|
def compare_alphanumerically(c, d)
|
||||||
|
is_c_bigger = c.size <=> d.size
|
||||||
|
if c.size > d.size
|
||||||
|
d += [nil] * (c.size - d.size)
|
||||||
|
elsif c.size < d.size
|
||||||
|
c += [nil] * (d.size - c.size)
|
||||||
|
end
|
||||||
|
c.zip(d) do |a, b|
|
||||||
|
return -1 if a.nil?
|
||||||
|
return 1 if b.nil?
|
||||||
|
if is_numeric(a) && is_numeric(b)
|
||||||
|
compare = a.to_big_i <=> b.to_big_i
|
||||||
|
return compare if compare != 0
|
||||||
|
else
|
||||||
|
compare = a <=> b
|
||||||
|
return compare if compare != 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
is_c_bigger
|
||||||
|
end
|
||||||
|
|
||||||
|
def compare_alphanumerically(a : String, b : String)
|
||||||
|
compare_alphanumerically split_by_alphanumeric(a), split_by_alphanumeric(b)
|
||||||
|
end
|
||||||
|
|
||||||
|
# When downloading from MangaDex, the zip/cbz file would not be valid
|
||||||
|
# before the download is completed. If we scan the zip file,
|
||||||
|
# Entry.new would throw, so we use this method to check before
|
||||||
|
# constructing Entry
|
||||||
|
def validate_zip(path : String) : Exception?
|
||||||
|
file = Zip::File.new path
|
||||||
|
file.close
|
||||||
|
return
|
||||||
|
rescue e
|
||||||
|
e
|
||||||
|
end
|
||||||
|
|
||||||
|
def random_str
|
||||||
|
UUID.random.to_s.gsub "-", ""
|
||||||
|
end
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
# Helper method used to sort chapters in a folder
|
|
||||||
# It respects the keywords like "Vol." and "Ch." in the filenames
|
|
||||||
# This sorting method was initially implemented in JS and done in the frontend.
|
|
||||||
# see https://github.com/hkalexling/Mango/blob/
|
|
||||||
# 07100121ef15260b5a8e8da0e5948c993df574c5/public/js/sort-items.js#L15-L87
|
|
||||||
|
|
||||||
require "big"
|
|
||||||
|
|
||||||
private class Item
|
|
||||||
getter numbers : Hash(String, BigDecimal)
|
|
||||||
|
|
||||||
def initialize(@numbers)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Compare with another Item using keys
|
|
||||||
def <=>(other : Item, keys : Array(String))
|
|
||||||
keys.each do |key|
|
|
||||||
if !@numbers.has_key?(key) && !other.numbers.has_key?(key)
|
|
||||||
next
|
|
||||||
elsif !@numbers.has_key? key
|
|
||||||
return 1
|
|
||||||
elsif !other.numbers.has_key? key
|
|
||||||
return -1
|
|
||||||
elsif @numbers[key] == other.numbers[key]
|
|
||||||
next
|
|
||||||
else
|
|
||||||
return @numbers[key] <=> other.numbers[key]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private class KeyRange
|
|
||||||
getter min : BigDecimal, max : BigDecimal, count : Int32
|
|
||||||
|
|
||||||
def initialize(value : BigDecimal)
|
|
||||||
@min = @max = value
|
|
||||||
@count = 1
|
|
||||||
end
|
|
||||||
|
|
||||||
def update(value : BigDecimal)
|
|
||||||
@min = value if value < @min
|
|
||||||
@max = value if value > @max
|
|
||||||
@count += 1
|
|
||||||
end
|
|
||||||
|
|
||||||
def range
|
|
||||||
@max - @min
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class ChapterSorter
|
|
||||||
@sorted_keys = [] of String
|
|
||||||
|
|
||||||
def initialize(str_ary : Array(String))
|
|
||||||
keys = {} of String => KeyRange
|
|
||||||
|
|
||||||
str_ary.each do |str|
|
|
||||||
scan str do |k, v|
|
|
||||||
if keys.has_key? k
|
|
||||||
keys[k].update v
|
|
||||||
else
|
|
||||||
keys[k] = KeyRange.new v
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Get the array of keys string and sort them
|
|
||||||
@sorted_keys = keys.keys
|
|
||||||
# Only use keys that are present in over half of the strings
|
|
||||||
.select do |key|
|
|
||||||
keys[key].count >= str_ary.size / 2
|
|
||||||
end
|
|
||||||
.sort! do |a_key, b_key|
|
|
||||||
a = keys[a_key]
|
|
||||||
b = keys[b_key]
|
|
||||||
# Sort keys by the number of times they appear
|
|
||||||
count_compare = b.count <=> a.count
|
|
||||||
if count_compare == 0
|
|
||||||
# Then sort by value range
|
|
||||||
b.range <=> a.range
|
|
||||||
else
|
|
||||||
count_compare
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def compare(a : String, b : String)
|
|
||||||
item_a = str_to_item a
|
|
||||||
item_b = str_to_item b
|
|
||||||
item_a.<=>(item_b, @sorted_keys)
|
|
||||||
end
|
|
||||||
|
|
||||||
private def scan(str, &)
|
|
||||||
str.scan /([^0-9\n\r\ ]*)[ ]*([0-9]*\.*[0-9]+)/ do |match|
|
|
||||||
key = match[1]
|
|
||||||
num = match[2].to_big_d
|
|
||||||
|
|
||||||
yield key, num
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private def str_to_item(str)
|
|
||||||
numbers = {} of String => BigDecimal
|
|
||||||
scan str do |k, v|
|
|
||||||
numbers[k] = v
|
|
||||||
end
|
|
||||||
Item.new numbers
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Properly sort alphanumeric strings
|
|
||||||
# Used to sort the images files inside the archives
|
|
||||||
# https://github.com/hkalexling/Mango/issues/12
|
|
||||||
|
|
||||||
require "big"
|
|
||||||
|
|
||||||
def is_numeric(str)
|
|
||||||
/^\d+/.match(str) != nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def split_by_alphanumeric(str)
|
|
||||||
arr = [] of String
|
|
||||||
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
|
|
||||||
arr += match.captures.select &.!= ""
|
|
||||||
end
|
|
||||||
arr
|
|
||||||
end
|
|
||||||
|
|
||||||
def compare_numerically(c, d)
|
|
||||||
is_c_bigger = c.size <=> d.size
|
|
||||||
if c.size > d.size
|
|
||||||
d += [nil] * (c.size - d.size)
|
|
||||||
elsif c.size < d.size
|
|
||||||
c += [nil] * (d.size - c.size)
|
|
||||||
end
|
|
||||||
c.zip(d) do |a, b|
|
|
||||||
return -1 if a.nil?
|
|
||||||
return 1 if b.nil?
|
|
||||||
if is_numeric(a) && is_numeric(b)
|
|
||||||
compare = a.to_big_i <=> b.to_big_i
|
|
||||||
return compare if compare != 0
|
|
||||||
else
|
|
||||||
compare = a <=> b
|
|
||||||
return compare if compare != 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
is_c_bigger
|
|
||||||
end
|
|
||||||
|
|
||||||
def compare_numerically(a : String, b : String)
|
|
||||||
compare_numerically split_by_alphanumeric(a), split_by_alphanumeric(b)
|
|
||||||
end
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
require "http_proxy"
|
|
||||||
|
|
||||||
# Monkey-patch `HTTP::Client` to make it respect the `*_PROXY`
|
|
||||||
# environment variables
|
|
||||||
module HTTP
|
|
||||||
class Client
|
|
||||||
private def self.exec(uri : URI, tls : TLSContext = nil)
|
|
||||||
Logger.debug "Setting proxy"
|
|
||||||
previous_def uri, tls do |client, path|
|
|
||||||
client.set_proxy get_proxy uri
|
|
||||||
yield client, path
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private def get_proxy(uri : URI) : HTTP::Proxy::Client?
|
|
||||||
no_proxy = ENV["no_proxy"]? || ENV["NO_PROXY"]?
|
|
||||||
return if no_proxy &&
|
|
||||||
no_proxy.split(",").any? &.== uri.hostname
|
|
||||||
|
|
||||||
case uri.scheme
|
|
||||||
when "http"
|
|
||||||
env_to_proxy "http_proxy"
|
|
||||||
when "https"
|
|
||||||
env_to_proxy "https_proxy"
|
|
||||||
else
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private def env_to_proxy(key : String) : HTTP::Proxy::Client?
|
|
||||||
val = ENV[key.downcase]? || ENV[key.upcase]?
|
|
||||||
return if val.nil?
|
|
||||||
|
|
||||||
begin
|
|
||||||
uri = URI.parse val
|
|
||||||
HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil!,
|
|
||||||
username: uri.user, password: uri.password
|
|
||||||
rescue
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
require "./util"
|
|
||||||
|
|
||||||
class File
|
|
||||||
abstract struct Info
|
|
||||||
def inode : UInt64
|
|
||||||
@stat.st_ino.to_u64
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Returns the signature of the file at filename.
|
|
||||||
# When it is not a supported file, returns 0. Otherwise, uses the inode
|
|
||||||
# number as its signature. On most file systems, the inode number is
|
|
||||||
# preserved even when the file is renamed, moved or edited.
|
|
||||||
# Some cases that would cause the inode number to change:
|
|
||||||
# - Reboot/remount on some file systems
|
|
||||||
# - Replaced with a copied file
|
|
||||||
# - Moved to a different device
|
|
||||||
# Since we are also using the relative paths to match ids, we won't lose
|
|
||||||
# information as long as the above changes do not happen together with
|
|
||||||
# a file/folder rename, with no library scan in between.
|
|
||||||
def self.signature(filename) : UInt64
|
|
||||||
if is_supported_file filename
|
|
||||||
File.info(filename).inode
|
|
||||||
else
|
|
||||||
0u64
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Dir
|
|
||||||
# Returns the signature of the directory at dirname. See the comments for
|
|
||||||
# `File.signature` for more information.
|
|
||||||
def self.signature(dirname) : UInt64
|
|
||||||
signatures = [File.info(dirname).inode]
|
|
||||||
self.open dirname do |dir|
|
|
||||||
dir.entries.each do |fn|
|
|
||||||
next if fn.starts_with? "."
|
|
||||||
path = File.join dirname, fn
|
|
||||||
if File.directory? path
|
|
||||||
signatures << Dir.signature path
|
|
||||||
else
|
|
||||||
_sig = File.signature path
|
|
||||||
# Only add its signature value to `signatures` when it is a
|
|
||||||
# supported file
|
|
||||||
signatures << _sig if _sig > 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
Digest::CRC32.checksum(signatures.sort.join).to_u64
|
|
||||||
end
|
|
||||||
|
|
||||||
# Returns the contents signature of the directory at dirname for checking
|
|
||||||
# to rescan.
|
|
||||||
# Rescan conditions:
|
|
||||||
# - When a file added, moved, removed, renamed (including which in nested
|
|
||||||
# directories)
|
|
||||||
def self.contents_signature(dirname, cache = {} of String => String) : String
|
|
||||||
return cache[dirname] if cache[dirname]?
|
|
||||||
Fiber.yield
|
|
||||||
signatures = [] of String
|
|
||||||
self.open dirname do |dir|
|
|
||||||
dir.entries.sort.each do |fn|
|
|
||||||
next if fn.starts_with? "."
|
|
||||||
path = File.join dirname, fn
|
|
||||||
if File.directory? path
|
|
||||||
signatures << Dir.contents_signature path, cache
|
|
||||||
else
|
|
||||||
# Only add its signature value to `signatures` when it is a
|
|
||||||
# supported file
|
|
||||||
signatures << fn if is_supported_file fn
|
|
||||||
end
|
|
||||||
Fiber.yield
|
|
||||||
end
|
|
||||||
end
|
|
||||||
hash = Digest::SHA1.hexdigest(signatures.join)
|
|
||||||
cache[dirname] = hash
|
|
||||||
hash
|
|
||||||
end
|
|
||||||
end
|
|
||||||
146
src/util/util.cr
146
src/util/util.cr
@@ -1,146 +0,0 @@
|
|||||||
IMGS_PER_PAGE = 5
|
|
||||||
ENTRIES_IN_HOME_SECTIONS = 8
|
|
||||||
UPLOAD_URL_PREFIX = "/uploads"
|
|
||||||
STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt)
|
|
||||||
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
|
|
||||||
|
|
||||||
def random_str
|
|
||||||
UUID.random.to_s.gsub "-", ""
|
|
||||||
end
|
|
||||||
|
|
||||||
# Works in all Unix systems. Follows https://github.com/crystal-lang/crystal/
|
|
||||||
# blob/master/src/crystal/system/unix/file_info.cr#L42-L48
|
|
||||||
def ctime(file_path : String) : Time
|
|
||||||
res = LibC.stat(file_path, out stat)
|
|
||||||
raise "Unable to get ctime of file #{file_path}" if res != 0
|
|
||||||
|
|
||||||
{% if flag?(:darwin) %}
|
|
||||||
Time.new stat.st_ctimespec, Time::Location::UTC
|
|
||||||
{% else %}
|
|
||||||
Time.new stat.st_ctim, Time::Location::UTC
|
|
||||||
{% end %}
|
|
||||||
end
|
|
||||||
|
|
||||||
def register_mime_types
|
|
||||||
{
|
|
||||||
# Comic Archives
|
|
||||||
".zip" => "application/zip",
|
|
||||||
".rar" => "application/x-rar-compressed",
|
|
||||||
".cbz" => "application/vnd.comicbook+zip",
|
|
||||||
".cbr" => "application/vnd.comicbook-rar",
|
|
||||||
|
|
||||||
# Favicon
|
|
||||||
".ico" => "image/x-icon",
|
|
||||||
|
|
||||||
# FontAwesome fonts
|
|
||||||
".woff" => "font/woff",
|
|
||||||
".woff2" => "font/woff2",
|
|
||||||
|
|
||||||
# Supported image formats. JPG, PNG, GIF, WebP, and SVG are already
|
|
||||||
# defiend by Crystal in `MIME.DEFAULT_TYPES`
|
|
||||||
".apng" => "image/apng",
|
|
||||||
".avif" => "image/avif",
|
|
||||||
}.each do |k, v|
|
|
||||||
MIME.register k, v
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def is_supported_file(path)
|
|
||||||
SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase
|
|
||||||
end
|
|
||||||
|
|
||||||
struct Int
|
|
||||||
def or(other : Int)
|
|
||||||
if self == 0
|
|
||||||
other
|
|
||||||
else
|
|
||||||
self
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
struct Nil
|
|
||||||
def or(other : Int)
|
|
||||||
other
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
macro use_default
|
|
||||||
def self.default : self
|
|
||||||
unless @@default
|
|
||||||
@@default = new
|
|
||||||
end
|
|
||||||
@@default.not_nil!
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class String
|
|
||||||
def alphanumeric_underscore?
|
|
||||||
self.chars.all? { |c| c.alphanumeric? || c == '_' }
|
|
||||||
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
|
|
||||||
|
|
||||||
def sort_titles(titles : Array(Title), opt : SortOptions, username : String)
|
|
||||||
ary = titles
|
|
||||||
|
|
||||||
case opt.method
|
|
||||||
when .time_modified?
|
|
||||||
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
|
|
||||||
compare_numerically a.title, b.title }
|
|
||||||
when .progress?
|
|
||||||
ary.sort! do |a, b|
|
|
||||||
(a.load_percentage(username) <=> b.load_percentage(username)).or \
|
|
||||||
compare_numerically a.title, b.title
|
|
||||||
end
|
|
||||||
else
|
|
||||||
unless opt.method.auto?
|
|
||||||
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
|
||||||
"Auto instead"
|
|
||||||
end
|
|
||||||
ary.sort! { |a, b| compare_numerically a.title, b.title }
|
|
||||||
end
|
|
||||||
|
|
||||||
ary.reverse! unless opt.not_nil!.ascend
|
|
||||||
|
|
||||||
ary
|
|
||||||
end
|
|
||||||
|
|
||||||
class String
|
|
||||||
# Returns the similarity (in [0, 1]) of two paths.
|
|
||||||
# For the two paths, separate them into arrays of components, count the
|
|
||||||
# number of matching components backwards, and divide the count by the
|
|
||||||
# number of components of the shorter path.
|
|
||||||
def components_similarity(other : String) : Float64
|
|
||||||
s, l = [self, other]
|
|
||||||
.map { |str| Path.new(str).parts }
|
|
||||||
.sort_by! &.size
|
|
||||||
|
|
||||||
match = s.reverse.zip(l.reverse).count { |a, b| a == b }
|
|
||||||
match / s.size
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Does the followings:
|
|
||||||
# - turns space-like characters into the normal whitespaces ( )
|
|
||||||
# - strips and collapses spaces
|
|
||||||
# - removes ASCII control characters
|
|
||||||
# - replaces slashes (/) with underscores (_)
|
|
||||||
# - removes leading dots (.)
|
|
||||||
# - removes the following special characters: \:*?"<>|
|
|
||||||
#
|
|
||||||
# If the sanitized string is empty, returns a random string instead.
|
|
||||||
def sanitize_filename(str : String) : String
|
|
||||||
sanitized = str
|
|
||||||
.gsub(/\s+/, " ")
|
|
||||||
.strip
|
|
||||||
.gsub(/\//, "_")
|
|
||||||
.gsub(/^[\.\s]+/, "")
|
|
||||||
.gsub(/[\177\000-\031\\:\*\?\"<>\|]/, "")
|
|
||||||
sanitized.size > 0 ? sanitized : random_str
|
|
||||||
end
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
def validate_username(username)
|
|
||||||
if username.size < 3
|
|
||||||
raise "Username should contain at least 3 characters"
|
|
||||||
end
|
|
||||||
if (username =~ /^[a-zA-Z_][a-zA-Z0-9_\-]*$/).nil?
|
|
||||||
raise "Username can only contain alphanumeric characters, " \
|
|
||||||
"underscores, and hyphens"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_password(password)
|
|
||||||
if password.size < 6
|
|
||||||
raise "Password should contain at least 6 characters"
|
|
||||||
end
|
|
||||||
if (password =~ /^[[:ascii:]]+$/).nil?
|
|
||||||
raise "password should contain ASCII characters only"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_archive(path : String) : Exception?
|
|
||||||
file = nil
|
|
||||||
begin
|
|
||||||
file = ArchiveFile.new path
|
|
||||||
file.check
|
|
||||||
file.close
|
|
||||||
return
|
|
||||||
rescue e
|
|
||||||
file.close unless file.nil?
|
|
||||||
e
|
|
||||||
end
|
|
||||||
end
|
|
||||||
145
src/util/web.cr
145
src/util/web.cr
@@ -1,145 +0,0 @@
|
|||||||
# Web related helper functions/macros
|
|
||||||
|
|
||||||
def is_admin?(env) : Bool
|
|
||||||
is_admin = false
|
|
||||||
if !Config.current.auth_proxy_header_name.empty? ||
|
|
||||||
Config.current.disable_login
|
|
||||||
is_admin = Storage.default.username_is_admin get_username env
|
|
||||||
end
|
|
||||||
|
|
||||||
# The token (if exists) takes precedence over other authentication methods.
|
|
||||||
if token = env.session.string? "token"
|
|
||||||
is_admin = Storage.default.verify_admin token
|
|
||||||
end
|
|
||||||
|
|
||||||
is_admin
|
|
||||||
end
|
|
||||||
|
|
||||||
macro layout(name)
|
|
||||||
base_url = Config.current.base_url
|
|
||||||
is_admin = is_admin? env
|
|
||||||
begin
|
|
||||||
page = {{name}}
|
|
||||||
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
|
|
||||||
rescue e
|
|
||||||
message = e.to_s
|
|
||||||
Logger.error message
|
|
||||||
page = "Error"
|
|
||||||
render "src/views/message.html.ecr", "src/views/layout.html.ecr"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
macro send_error_page(msg)
|
|
||||||
message = {{msg}}
|
|
||||||
base_url = Config.current.base_url
|
|
||||||
is_admin = is_admin? env
|
|
||||||
page = "Error"
|
|
||||||
html = render "src/views/message.html.ecr", "src/views/layout.html.ecr"
|
|
||||||
send_file env, html.to_slice, "text/html"
|
|
||||||
end
|
|
||||||
|
|
||||||
macro send_img(env, img)
|
|
||||||
send_file {{env}}, {{img}}.data, {{img}}.mime
|
|
||||||
end
|
|
||||||
|
|
||||||
macro get_username(env)
|
|
||||||
begin
|
|
||||||
token = env.session.string "token"
|
|
||||||
(Storage.default.verify_token token).not_nil!
|
|
||||||
rescue e
|
|
||||||
if Config.current.disable_login
|
|
||||||
Config.current.default_username
|
|
||||||
elsif (header = Config.current.auth_proxy_header_name) && !header.empty?
|
|
||||||
env.request.headers[header]
|
|
||||||
else
|
|
||||||
raise e
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def send_json(env, json)
|
|
||||||
env.response.content_type = "application/json"
|
|
||||||
env.response.print json
|
|
||||||
end
|
|
||||||
|
|
||||||
def send_attachment(env, path)
|
|
||||||
send_file env, path, filename: File.basename(path), disposition: "attachment"
|
|
||||||
end
|
|
||||||
|
|
||||||
def redirect(env, path)
|
|
||||||
base = Config.current.base_url
|
|
||||||
env.redirect File.join base, path
|
|
||||||
end
|
|
||||||
|
|
||||||
def hash_to_query(hash)
|
|
||||||
hash.join "&" { |k, v| "#{k}=#{v}" }
|
|
||||||
end
|
|
||||||
|
|
||||||
def request_path_startswith(env, ary)
|
|
||||||
ary.any? { |prefix| env.request.path.starts_with? prefix }
|
|
||||||
end
|
|
||||||
|
|
||||||
def requesting_static_file(env)
|
|
||||||
request_path_startswith env, STATIC_DIRS
|
|
||||||
end
|
|
||||||
|
|
||||||
macro render_xml(path)
|
|
||||||
base_url = Config.current.base_url
|
|
||||||
send_file env, ECR.render({{path}}).to_slice, "application/xml"
|
|
||||||
end
|
|
||||||
|
|
||||||
macro render_component(filename)
|
|
||||||
render "src/views/components/#{{{filename}}}.html.ecr"
|
|
||||||
end
|
|
||||||
|
|
||||||
macro get_sort_opt
|
|
||||||
sort_method = env.params.query["sort"]?
|
|
||||||
|
|
||||||
if sort_method
|
|
||||||
is_ascending = true
|
|
||||||
|
|
||||||
ascend = env.params.query["ascend"]?
|
|
||||||
if ascend && ascend.to_i? == 0
|
|
||||||
is_ascending = false
|
|
||||||
end
|
|
||||||
|
|
||||||
sort_opt = SortOptions.new sort_method, is_ascending
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
macro get_and_save_sort_opt(dir)
|
|
||||||
sort_method = env.params.query["sort"]?
|
|
||||||
|
|
||||||
if sort_method
|
|
||||||
is_ascending = true
|
|
||||||
|
|
||||||
ascend = env.params.query["ascend"]?
|
|
||||||
if ascend && ascend.to_i? == 0
|
|
||||||
is_ascending = false
|
|
||||||
end
|
|
||||||
|
|
||||||
sort_opt = SortOptions.new sort_method, is_ascending
|
|
||||||
|
|
||||||
TitleInfo.new {{dir}} do |info|
|
|
||||||
info.sort_by[username] = sort_opt.to_tuple
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
module HTTP
|
|
||||||
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
|
|
||||||
18
src/views/admin.ecr
Normal file
18
src/views/admin.ecr
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<ul class="uk-list uk-list-large uk-list-divider">
|
||||||
|
<li data-url="/admin/user">User Managerment</li>
|
||||||
|
<li onclick="if(!scanning){scan()}">
|
||||||
|
<span id="scan">Scan Library Files</span>
|
||||||
|
<span id="scan-status" class="uk-align-right">
|
||||||
|
<div uk-spinner hidden></div>
|
||||||
|
<span hidden></span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li data-url="/admin/downloads">Download Manager</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr class="uk-divider-icon">
|
||||||
|
<a class="uk-button uk-button-danger" href="/logout">Log Out</a>
|
||||||
|
|
||||||
|
<% content_for "script" do %>
|
||||||
|
<script src="/js/admin.js"></script>
|
||||||
|
<% end %>
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<ul class="uk-list uk-list-large uk-list-divider" x-data="component()" x-init="init()">
|
|
||||||
<li><a class="uk-link-reset" href="<%= base_url %>admin/user">User Management</a></li>
|
|
||||||
<li>
|
|
||||||
<a class="uk-link-reset" href="<%= base_url %>admin/missing">Missing Items</a>
|
|
||||||
<% if missing_count > 0 %>
|
|
||||||
<div class="uk-align-right">
|
|
||||||
<span class="uk-badge"><%= missing_count %></span>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="uk-link-reset" @click="scan()">
|
|
||||||
<span :style="`${scanning ? 'color:grey' : ''}`">Scan Library Files</span>
|
|
||||||
<div class="uk-align-right">
|
|
||||||
<div uk-spinner x-show="scanning"></div>
|
|
||||||
<span x-show="!scanning && scanMs > 0" x-text="`Scan ${scanTitles} titles in ${scanMs}ms`"></span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="uk-link-reset" @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>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span>Theme</span>
|
|
||||||
<select id="theme-select" class="uk-select uk-align-right uk-width-1-3@m uk-width-1-2" :value="themeSetting" @change="themeChanged($event)">
|
|
||||||
<option>Dark</option>
|
|
||||||
<option>Light</option>
|
|
||||||
<option>System</option>
|
|
||||||
</select>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<hr class="uk-divider-icon">
|
|
||||||
<p class="uk-text-meta">Version: v<%= MANGO_VERSION %></p>
|
|
||||||
<a class="uk-button uk-button-danger" href="<%= base_url %>logout">Log Out</a>
|
|
||||||
|
|
||||||
<% content_for "script" do %>
|
|
||||||
<script src="<%= base_url %>js/admin.js"></script>
|
|
||||||
<% end %>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user