mirror of
https://github.com/hkalexling/Mango.git
synced 2025-08-03 11:25:29 -04:00
commit
8d84a3c502
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@ -12,12 +12,12 @@ jobs:
|
|||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: crystallang/crystal:0.35.1-alpine
|
image: crystallang/crystal:1.0.0-alpine
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
run: apk add --no-cache yarn yaml-static sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
||||||
- name: Build
|
- name: Build
|
||||||
run: make static || make static
|
run: make static || make static
|
||||||
- name: Linter
|
- name: Linter
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
FROM crystallang/crystal:0.35.1-alpine AS builder
|
FROM crystallang/crystal:1.0.0-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /Mango
|
WORKDIR /Mango
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
RUN apk add --no-cache yarn yaml-static sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
||||||
RUN make static || make static
|
RUN make static || make static
|
||||||
|
|
||||||
FROM library/alpine
|
FROM library/alpine
|
||||||
|
@ -2,10 +2,10 @@ FROM arm32v7/ubuntu:18.04
|
|||||||
|
|
||||||
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
||||||
|
|
||||||
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
|
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 1.0.0 && make deps && cd ..
|
||||||
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
|
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.8 && make && cd ..
|
||||||
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
|
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v1.0.0 && make && cd ..
|
||||||
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
|
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.5.0 && make && cd ..
|
||||||
|
|
||||||
COPY mango-arm32v7.o .
|
COPY mango-arm32v7.o .
|
||||||
|
|
||||||
|
@ -2,10 +2,10 @@ FROM arm64v8/ubuntu:18.04
|
|||||||
|
|
||||||
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
||||||
|
|
||||||
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
|
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 1.0.0 && make deps && cd ..
|
||||||
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
|
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.8 && make && cd ..
|
||||||
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
|
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v1.0.0 && make && cd ..
|
||||||
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
|
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.5.0 && make && cd ..
|
||||||
|
|
||||||
COPY mango-arm64v8.o .
|
COPY mango-arm64v8.o .
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Mango
|
# Mango
|
||||||
|
|
||||||
[](https://www.patreon.com/hkalexling)  [](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
|
[](https://www.patreon.com/hkalexling)  [](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [](http://discord.com/invite/ezKtacCp9Q)
|
||||||
|
|
||||||
Mango is a self-hosted manga server and reader. Its features include
|
Mango is a self-hosted manga server and reader. Its features include
|
||||||
|
|
||||||
@ -13,7 +13,6 @@ Mango is a self-hosted manga server and reader. Its features include
|
|||||||
- Supports nested folders in library
|
- Supports nested folders in library
|
||||||
- Automatically stores reading progress
|
- Automatically stores reading progress
|
||||||
- Thumbnail generation
|
- Thumbnail generation
|
||||||
- Built-in [MangaDex](https://mangadex.org/) downloader
|
|
||||||
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites
|
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites
|
||||||
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
||||||
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
||||||
@ -52,7 +51,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
|||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
```
|
```
|
||||||
Mango - Manga Server and Web Reader. Version 0.21.0
|
Mango - Manga Server and Web Reader. Version 0.23.0
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
@ -87,7 +86,6 @@ log_level: info
|
|||||||
upload_path: ~/mango/uploads
|
upload_path: ~/mango/uploads
|
||||||
plugin_path: ~/mango/plugins
|
plugin_path: ~/mango/plugins
|
||||||
download_timeout_seconds: 30
|
download_timeout_seconds: 30
|
||||||
page_margin: 30
|
|
||||||
disable_login: false
|
disable_login: false
|
||||||
default_username: ""
|
default_username: ""
|
||||||
auth_proxy_header_name: ""
|
auth_proxy_header_name: ""
|
||||||
@ -99,6 +97,7 @@ mangadex:
|
|||||||
download_queue_db_path: ~/mango/queue.db
|
download_queue_db_path: ~/mango/queue.db
|
||||||
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
|
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
|
||||||
manga_rename_rule: '{title}'
|
manga_rename_rule: '{title}'
|
||||||
|
subscription_update_interval_hours: 24
|
||||||
```
|
```
|
||||||
|
|
||||||
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
||||||
|
@ -1,287 +0,0 @@
|
|||||||
const downloadComponent = () => {
|
|
||||||
return {
|
|
||||||
chaptersLimit: 1000,
|
|
||||||
loading: false,
|
|
||||||
addingToDownload: false,
|
|
||||||
searchAvailable: false,
|
|
||||||
searchInput: '',
|
|
||||||
data: {},
|
|
||||||
chapters: [],
|
|
||||||
mangaAry: undefined, // undefined: not searching; []: searched but no result
|
|
||||||
candidateManga: {},
|
|
||||||
langChoice: 'All',
|
|
||||||
groupChoice: 'All',
|
|
||||||
chapterRange: '',
|
|
||||||
volumeRange: '',
|
|
||||||
|
|
||||||
get languages() {
|
|
||||||
const set = new Set();
|
|
||||||
if (this.data.chapters) {
|
|
||||||
this.data.chapters.forEach(chp => {
|
|
||||||
set.add(chp.language);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const ary = [...set].sort();
|
|
||||||
ary.unshift('All');
|
|
||||||
return ary;
|
|
||||||
},
|
|
||||||
|
|
||||||
get groups() {
|
|
||||||
const set = new Set();
|
|
||||||
if (this.data.chapters) {
|
|
||||||
this.data.chapters.forEach(chp => {
|
|
||||||
Object.keys(chp.groups).forEach(g => {
|
|
||||||
set.add(g);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const ary = [...set].sort();
|
|
||||||
ary.unshift('All');
|
|
||||||
return ary;
|
|
||||||
},
|
|
||||||
|
|
||||||
init() {
|
|
||||||
const tableObserver = new MutationObserver(() => {
|
|
||||||
console.log('table mutated');
|
|
||||||
$("#selectable").selectable({
|
|
||||||
filter: 'tr'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
tableObserver.observe($('table').get(0), {
|
|
||||||
childList: true,
|
|
||||||
subtree: true
|
|
||||||
});
|
|
||||||
|
|
||||||
$.getJSON(`${base_url}api/admin/mangadex/expires`)
|
|
||||||
.done((data) => {
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.expires && data.expires > Math.floor(Date.now() / 1000))
|
|
||||||
this.searchAvailable = true;
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
filtersUpdated() {
|
|
||||||
if (!this.data.chapters)
|
|
||||||
this.chapters = [];
|
|
||||||
const filters = {
|
|
||||||
chapter: this.parseRange(this.chapterRange),
|
|
||||||
volume: this.parseRange(this.volumeRange),
|
|
||||||
lang: this.langChoice,
|
|
||||||
group: this.groupChoice
|
|
||||||
};
|
|
||||||
console.log('filters:', filters);
|
|
||||||
let _chapters = this.data.chapters.slice();
|
|
||||||
Object.entries(filters).forEach(([k, v]) => {
|
|
||||||
if (v === 'All') return;
|
|
||||||
if (k === 'group') {
|
|
||||||
_chapters = _chapters.filter(c => {
|
|
||||||
const unescaped_groups = Object.entries(c.groups).map(([g, id]) => this.unescapeHTML(g));
|
|
||||||
return unescaped_groups.indexOf(v) >= 0;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (k === 'lang') {
|
|
||||||
_chapters = _chapters.filter(c => c.language === v);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lb = parseFloat(v[0]);
|
|
||||||
const ub = parseFloat(v[1]);
|
|
||||||
if (isNaN(lb) && isNaN(ub)) return;
|
|
||||||
_chapters = _chapters.filter(c => {
|
|
||||||
const val = parseFloat(c[k]);
|
|
||||||
if (isNaN(val)) return false;
|
|
||||||
if (isNaN(lb))
|
|
||||||
return val <= ub;
|
|
||||||
else if (isNaN(ub))
|
|
||||||
return val >= lb;
|
|
||||||
else
|
|
||||||
return val >= lb && val <= ub;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
console.log('filtered chapters:', _chapters);
|
|
||||||
this.chapters = _chapters;
|
|
||||||
},
|
|
||||||
|
|
||||||
search() {
|
|
||||||
if (this.loading || this.searchInput === '') return;
|
|
||||||
this.data = {};
|
|
||||||
this.mangaAry = undefined;
|
|
||||||
|
|
||||||
var int_id = -1;
|
|
||||||
try {
|
|
||||||
const path = new URL(this.searchInput).pathname;
|
|
||||||
const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
|
|
||||||
int_id = parseInt(match[1]);
|
|
||||||
} catch (e) {
|
|
||||||
int_id = parseInt(this.searchInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isNaN(int_id) && int_id > 0) {
|
|
||||||
// The input is a positive integer. We treat it as an ID.
|
|
||||||
this.loading = true;
|
|
||||||
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
|
|
||||||
.done((data) => {
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', 'Failed to get manga info. Error: ' + data.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.data = data;
|
|
||||||
this.chapters = data.chapters;
|
|
||||||
this.mangaAry = undefined;
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (!this.searchAvailable) {
|
|
||||||
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex. If you are trying to search MangaDex with a search term, please log in to MangaDex first by going to "Admin -> Connect to MangaDex".');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search as a search term
|
|
||||||
this.loading = true;
|
|
||||||
$.getJSON(`${base_url}api/admin/mangadex/search?${$.param({
|
|
||||||
query: this.searchInput
|
|
||||||
})}`)
|
|
||||||
.done((data) => {
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to search MangaDex. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.mangaAry = data.manga;
|
|
||||||
this.data = {};
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to search MangaDex. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
parseRange(str) {
|
|
||||||
const regex = /^[\t ]*(?:(?:(<|<=|>|>=)[\t ]*([0-9]+))|(?:([0-9]+))|(?:([0-9]+)[\t ]*-[\t ]*([0-9]+))|(?:[\t ]*))[\t ]*$/m;
|
|
||||||
const matches = str.match(regex);
|
|
||||||
var num;
|
|
||||||
|
|
||||||
if (!matches) {
|
|
||||||
return [null, null];
|
|
||||||
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
|
|
||||||
// e.g., <= 30
|
|
||||||
num = parseInt(matches[2]);
|
|
||||||
if (isNaN(num)) {
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
switch (matches[1]) {
|
|
||||||
case '<':
|
|
||||||
return [null, num - 1];
|
|
||||||
case '<=':
|
|
||||||
return [null, num];
|
|
||||||
case '>':
|
|
||||||
return [num + 1, null];
|
|
||||||
case '>=':
|
|
||||||
return [num, null];
|
|
||||||
}
|
|
||||||
} else if (typeof matches[3] !== 'undefined') {
|
|
||||||
// a single number
|
|
||||||
num = parseInt(matches[3]);
|
|
||||||
if (isNaN(num)) {
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
return [num, num];
|
|
||||||
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
|
|
||||||
// e.g., 10 - 23
|
|
||||||
num = parseInt(matches[4]);
|
|
||||||
const n2 = parseInt(matches[5]);
|
|
||||||
if (isNaN(num) || isNaN(n2) || num > n2) {
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
return [num, n2];
|
|
||||||
} else {
|
|
||||||
// empty or space only
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
unescapeHTML(str) {
|
|
||||||
var elt = document.createElement("span");
|
|
||||||
elt.innerHTML = str;
|
|
||||||
return elt.innerText;
|
|
||||||
},
|
|
||||||
|
|
||||||
selectAll() {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).addClass('ui-selected');
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
clearSelection() {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).removeClass('ui-selected');
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
download() {
|
|
||||||
const selected = $('tbody > tr.ui-selected');
|
|
||||||
if (selected.length === 0) return;
|
|
||||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
|
||||||
const ids = selected.map((i, e) => {
|
|
||||||
return parseInt($(e).find('td').first().text());
|
|
||||||
}).get();
|
|
||||||
const chapters = this.chapters.filter(c => ids.indexOf(c.id) >= 0);
|
|
||||||
console.log(ids);
|
|
||||||
this.addingToDownload = true;
|
|
||||||
$.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: `${base_url}api/admin/mangadex/download`,
|
|
||||||
data: JSON.stringify({
|
|
||||||
chapters: chapters
|
|
||||||
}),
|
|
||||||
contentType: "application/json",
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
console.log(data);
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const successCount = parseInt(data.success);
|
|
||||||
const failCount = parseInt(data.fail);
|
|
||||||
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
|
||||||
window.location.href = base_url + 'admin/downloads';
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
this.addingToDownload = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
chooseManga(manga) {
|
|
||||||
this.candidateManga = manga;
|
|
||||||
UIkit.modal($('#modal').get(0)).show();
|
|
||||||
},
|
|
||||||
|
|
||||||
confirmManga(id) {
|
|
||||||
UIkit.modal($('#modal').get(0)).hide();
|
|
||||||
this.searchInput = id;
|
|
||||||
this.search();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,61 +0,0 @@
|
|||||||
const component = () => {
|
|
||||||
return {
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
expires: undefined,
|
|
||||||
loading: true,
|
|
||||||
loggingIn: false,
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.loading = true;
|
|
||||||
$.ajax({
|
|
||||||
type: 'GET',
|
|
||||||
url: `${base_url}api/admin/mangadex/expires`,
|
|
||||||
contentType: "application/json",
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
console.log(data);
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to retrieve MangaDex token status. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.expires = data.expires;
|
|
||||||
this.loading = false;
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to retrieve MangaDex token status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
login() {
|
|
||||||
if (!(this.username && this.password)) return;
|
|
||||||
this.loggingIn = true;
|
|
||||||
$.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: `${base_url}api/admin/mangadex/login`,
|
|
||||||
contentType: "application/json",
|
|
||||||
dataType: 'json',
|
|
||||||
data: JSON.stringify({
|
|
||||||
username: this.username,
|
|
||||||
password: this.password
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
console.log(data);
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to log in. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.expires = data.expires;
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to log in. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
this.loggingIn = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
get expired() {
|
|
||||||
return this.expires && moment().diff(moment.unix(this.expires)) > 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
@ -126,9 +126,7 @@ const download = () => {
|
|||||||
}
|
}
|
||||||
const successCount = parseInt(data.success);
|
const successCount = parseInt(data.success);
|
||||||
const failCount = parseInt(data.fail);
|
const failCount = parseInt(data.fail);
|
||||||
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`);
|
||||||
window.location.href = base_url + 'admin/downloads';
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.fail((jqXHR, status) => {
|
.fail((jqXHR, status) => {
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
@ -6,10 +6,13 @@ const readerComponent = () => {
|
|||||||
alertClass: 'uk-alert-primary',
|
alertClass: 'uk-alert-primary',
|
||||||
items: [],
|
items: [],
|
||||||
curItem: {},
|
curItem: {},
|
||||||
|
enableFlipAnimation: true,
|
||||||
flipAnimation: null,
|
flipAnimation: null,
|
||||||
longPages: false,
|
longPages: false,
|
||||||
lastSavedPage: page,
|
lastSavedPage: page,
|
||||||
selectedIndex: 0, // 0: not selected; 1: the first page
|
selectedIndex: 0, // 0: not selected; 1: the first page
|
||||||
|
margin: 30,
|
||||||
|
preloadLookahead: 3,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the component by fetching the page dimensions
|
* Initialize the component by fetching the page dimensions
|
||||||
@ -27,7 +30,6 @@ const readerComponent = () => {
|
|||||||
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
|
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
|
||||||
width: d.width,
|
width: d.width,
|
||||||
height: d.height,
|
height: d.height,
|
||||||
style: `margin-top: ${data.margin}px; margin-bottom: ${data.margin}px;`
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -47,6 +49,21 @@ const readerComponent = () => {
|
|||||||
const mode = this.mode;
|
const mode = this.mode;
|
||||||
this.updateMode(this.mode, page, nextTick);
|
this.updateMode(this.mode, page, nextTick);
|
||||||
$('#mode-select').val(mode);
|
$('#mode-select').val(mode);
|
||||||
|
|
||||||
|
const savedMargin = localStorage.getItem('margin');
|
||||||
|
if (savedMargin) {
|
||||||
|
this.margin = savedMargin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload Images
|
||||||
|
this.preloadLookahead = +(localStorage.getItem('preloadLookahead') ?? 3);
|
||||||
|
const limit = Math.min(page + this.preloadLookahead, this.items.length + 1);
|
||||||
|
for (let idx = page + 1; idx <= limit; idx++) {
|
||||||
|
this.preloadImage(this.items[idx - 1].url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedFlipAnimation = localStorage.getItem('enableFlipAnimation');
|
||||||
|
this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true';
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
const errMsg = `Failed to get the page dimensions. ${e}`;
|
const errMsg = `Failed to get the page dimensions. ${e}`;
|
||||||
@ -55,6 +72,12 @@ const readerComponent = () => {
|
|||||||
this.msg = errMsg;
|
this.msg = errMsg;
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Preload an image, which is expected to be cached
|
||||||
|
*/
|
||||||
|
preloadImage(url) {
|
||||||
|
(new Image()).src = url;
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Handles the `change` event for the page selector
|
* Handles the `change` event for the page selector
|
||||||
*/
|
*/
|
||||||
@ -106,12 +129,18 @@ const readerComponent = () => {
|
|||||||
|
|
||||||
if (newIdx <= 0 || newIdx > this.items.length) return;
|
if (newIdx <= 0 || newIdx > this.items.length) return;
|
||||||
|
|
||||||
|
if (newIdx + this.preloadLookahead < this.items.length + 1) {
|
||||||
|
this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url);
|
||||||
|
}
|
||||||
|
|
||||||
this.toPage(newIdx);
|
this.toPage(newIdx);
|
||||||
|
|
||||||
if (isNext)
|
if (this.enableFlipAnimation) {
|
||||||
this.flipAnimation = 'right';
|
if (isNext)
|
||||||
else
|
this.flipAnimation = 'right';
|
||||||
this.flipAnimation = 'left';
|
else
|
||||||
|
this.flipAnimation = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.flipAnimation = null;
|
this.flipAnimation = null;
|
||||||
@ -277,6 +306,19 @@ const readerComponent = () => {
|
|||||||
entryChanged() {
|
entryChanged() {
|
||||||
const id = $('#entry-select').val();
|
const id = $('#entry-select').val();
|
||||||
this.redirect(`${base_url}reader/${tid}/${id}`);
|
this.redirect(`${base_url}reader/${tid}/${id}`);
|
||||||
}
|
},
|
||||||
|
|
||||||
|
marginChanged() {
|
||||||
|
localStorage.setItem('margin', this.margin);
|
||||||
|
this.toPage(this.selectedIndex);
|
||||||
|
},
|
||||||
|
|
||||||
|
preloadLookaheadChanged() {
|
||||||
|
localStorage.setItem('preloadLookahead', this.preloadLookahead);
|
||||||
|
},
|
||||||
|
|
||||||
|
enableFlipAnimationChanged() {
|
||||||
|
localStorage.setItem('enableFlipAnimation', this.enableFlipAnimation);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
82
public/js/subscription.js
Normal file
82
public/js/subscription.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
const component = () => {
|
||||||
|
return {
|
||||||
|
available: undefined,
|
||||||
|
subscriptions: [],
|
||||||
|
|
||||||
|
init() {
|
||||||
|
$.getJSON(`${base_url}api/admin/mangadex/expires`)
|
||||||
|
.done((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.available = Boolean(data.expires && data.expires > Math.floor(Date.now() / 1000));
|
||||||
|
|
||||||
|
if (this.available) this.getSubscriptions();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
getSubscriptions() {
|
||||||
|
$.getJSON(`${base_url}api/admin/mangadex/subscriptions`)
|
||||||
|
.done(data => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', 'Failed to get subscriptions. Error: ' + data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.subscriptions = data.subscriptions;
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to get subscriptions. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
rm(event) {
|
||||||
|
const id = event.currentTarget.parentNode.getAttribute('data-id');
|
||||||
|
$.ajax({
|
||||||
|
type: 'DELETE',
|
||||||
|
url: `${base_url}api/admin/mangadex/subscriptions/${id}`,
|
||||||
|
contentType: 'application/json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to delete subscription. Error: ${data.error}`);
|
||||||
|
}
|
||||||
|
this.getSubscriptions();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to delete subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
check(event) {
|
||||||
|
const id = event.currentTarget.parentNode.getAttribute('data-id');
|
||||||
|
$.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`,
|
||||||
|
contentType: 'application/json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to check subscription. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert('success', 'Mango is now checking the subscription for updates. This might take a while, but you can safely leave the page.');
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to check subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
formatRange(min, max) {
|
||||||
|
if (!isNaN(min) && isNaN(max)) return `≥ ${min}`;
|
||||||
|
if (isNaN(min) && !isNaN(max)) return `≤ ${max}`;
|
||||||
|
if (isNaN(min) && isNaN(max)) return 'All';
|
||||||
|
|
||||||
|
if (min === max) return `= ${min}`;
|
||||||
|
return `${min} - ${max}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
44
shard.lock
44
shard.lock
@ -2,81 +2,77 @@ version: 2.0
|
|||||||
shards:
|
shards:
|
||||||
ameba:
|
ameba:
|
||||||
git: https://github.com/crystal-ameba/ameba.git
|
git: https://github.com/crystal-ameba/ameba.git
|
||||||
version: 0.12.1
|
version: 0.14.3
|
||||||
|
|
||||||
archive:
|
archive:
|
||||||
git: https://github.com/hkalexling/archive.cr.git
|
git: https://github.com/hkalexling/archive.cr.git
|
||||||
version: 0.4.0
|
version: 0.5.0
|
||||||
|
|
||||||
baked_file_system:
|
baked_file_system:
|
||||||
git: https://github.com/schovi/baked_file_system.git
|
git: https://github.com/schovi/baked_file_system.git
|
||||||
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
|
version: 0.10.0
|
||||||
|
|
||||||
clim:
|
clim:
|
||||||
git: https://github.com/at-grandpa/clim.git
|
git: https://github.com/at-grandpa/clim.git
|
||||||
version: 0.12.0
|
version: 0.17.1
|
||||||
|
|
||||||
db:
|
db:
|
||||||
git: https://github.com/crystal-lang/crystal-db.git
|
git: https://github.com/crystal-lang/crystal-db.git
|
||||||
version: 0.9.0
|
version: 0.10.1
|
||||||
|
|
||||||
duktape:
|
duktape:
|
||||||
git: https://github.com/jessedoyle/duktape.cr.git
|
git: https://github.com/jessedoyle/duktape.cr.git
|
||||||
version: 0.20.0
|
version: 1.0.0
|
||||||
|
|
||||||
exception_page:
|
exception_page:
|
||||||
git: https://github.com/crystal-loot/exception_page.git
|
git: https://github.com/crystal-loot/exception_page.git
|
||||||
version: 0.1.4
|
version: 0.1.5
|
||||||
|
|
||||||
http_proxy:
|
http_proxy:
|
||||||
git: https://github.com/mamantoha/http_proxy.git
|
git: https://github.com/mamantoha/http_proxy.git
|
||||||
version: 0.7.1
|
version: 0.8.0
|
||||||
|
|
||||||
image_size:
|
image_size:
|
||||||
git: https://github.com/hkalexling/image_size.cr.git
|
git: https://github.com/hkalexling/image_size.cr.git
|
||||||
version: 0.4.0
|
version: 0.5.0
|
||||||
|
|
||||||
kemal:
|
kemal:
|
||||||
git: https://github.com/kemalcr/kemal.git
|
git: https://github.com/kemalcr/kemal.git
|
||||||
version: 0.27.0
|
version: 1.0.0
|
||||||
|
|
||||||
kemal-session:
|
kemal-session:
|
||||||
git: https://github.com/kemalcr/kemal-session.git
|
git: https://github.com/kemalcr/kemal-session.git
|
||||||
version: 0.12.1
|
version: 1.0.0
|
||||||
|
|
||||||
kilt:
|
kilt:
|
||||||
git: https://github.com/jeromegn/kilt.git
|
git: https://github.com/jeromegn/kilt.git
|
||||||
version: 0.4.0
|
version: 0.4.1
|
||||||
|
|
||||||
koa:
|
koa:
|
||||||
git: https://github.com/hkalexling/koa.git
|
git: https://github.com/hkalexling/koa.git
|
||||||
version: 0.7.0
|
version: 0.8.0
|
||||||
|
|
||||||
mangadex:
|
|
||||||
git: https://github.com/hkalexling/mangadex.git
|
|
||||||
version: 0.8.0+git.commit.24e6fb51afd043721139355854e305b43bf98c43
|
|
||||||
|
|
||||||
mg:
|
mg:
|
||||||
git: https://github.com/hkalexling/mg.git
|
git: https://github.com/hkalexling/mg.git
|
||||||
version: 0.3.0+git.commit.a19417abf03eece80039f89569926cff1ce3a1a3
|
version: 0.5.0+git.commit.697e46e27cde8c3969346e228e372db2455a6264
|
||||||
|
|
||||||
myhtml:
|
myhtml:
|
||||||
git: https://github.com/kostya/myhtml.git
|
git: https://github.com/kostya/myhtml.git
|
||||||
version: 1.5.1
|
version: 1.5.8
|
||||||
|
|
||||||
open_api:
|
open_api:
|
||||||
git: https://github.com/jreinert/open_api.cr.git
|
git: https://github.com/hkalexling/open_api.cr.git
|
||||||
version: 1.2.1+git.commit.95e4df2ca10b1fe88b8b35c62a18b06a10267b6c
|
version: 1.2.1+git.commit.1d3c55dd5534c6b0af18964d031858a08515553a
|
||||||
|
|
||||||
radix:
|
radix:
|
||||||
git: https://github.com/luislavena/radix.git
|
git: https://github.com/luislavena/radix.git
|
||||||
version: 0.3.9
|
version: 0.4.1
|
||||||
|
|
||||||
sqlite3:
|
sqlite3:
|
||||||
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
||||||
version: 0.16.0
|
version: 0.18.0
|
||||||
|
|
||||||
tallboy:
|
tallboy:
|
||||||
git: https://github.com/epoch/tallboy.git
|
git: https://github.com/epoch/tallboy.git
|
||||||
version: 0.9.3
|
version: 0.9.3+git.commit.9be1510bb0391c95e92f1b288f3afb429a73caa6
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
name: mango
|
name: mango
|
||||||
version: 0.21.0
|
version: 0.23.0
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Alex Ling <hkalexling@gmail.com>
|
- Alex Ling <hkalexling@gmail.com>
|
||||||
@ -8,7 +8,7 @@ targets:
|
|||||||
mango:
|
mango:
|
||||||
main: src/mango.cr
|
main: src/mango.cr
|
||||||
|
|
||||||
crystal: 0.35.1
|
crystal: 1.0.0
|
||||||
|
|
||||||
license: MIT
|
license: MIT
|
||||||
|
|
||||||
@ -21,7 +21,6 @@ dependencies:
|
|||||||
github: crystal-lang/crystal-sqlite3
|
github: crystal-lang/crystal-sqlite3
|
||||||
baked_file_system:
|
baked_file_system:
|
||||||
github: schovi/baked_file_system
|
github: schovi/baked_file_system
|
||||||
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
|
|
||||||
archive:
|
archive:
|
||||||
github: hkalexling/archive.cr
|
github: hkalexling/archive.cr
|
||||||
ameba:
|
ameba:
|
||||||
@ -30,7 +29,6 @@ dependencies:
|
|||||||
github: at-grandpa/clim
|
github: at-grandpa/clim
|
||||||
duktape:
|
duktape:
|
||||||
github: jessedoyle/duktape.cr
|
github: jessedoyle/duktape.cr
|
||||||
version: ~> 0.20.0
|
|
||||||
myhtml:
|
myhtml:
|
||||||
github: kostya/myhtml
|
github: kostya/myhtml
|
||||||
http_proxy:
|
http_proxy:
|
||||||
@ -41,7 +39,6 @@ dependencies:
|
|||||||
github: hkalexling/koa
|
github: hkalexling/koa
|
||||||
tallboy:
|
tallboy:
|
||||||
github: epoch/tallboy
|
github: epoch/tallboy
|
||||||
|
branch: master
|
||||||
mg:
|
mg:
|
||||||
github: hkalexling/mg
|
github: hkalexling/mg
|
||||||
mangadex:
|
|
||||||
github: hkalexling/mangadex
|
|
||||||
|
@ -8,9 +8,7 @@ describe Storage do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it "deletes user" do
|
it "deletes user" do
|
||||||
with_storage do |storage|
|
with_storage &.delete_user "admin"
|
||||||
storage.delete_user "admin"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it "creates new user" do
|
it "creates new user" do
|
||||||
|
@ -21,7 +21,7 @@ describe "compare_numerically" do
|
|||||||
it "sorts like the stack exchange post" do
|
it "sorts like the stack exchange post" do
|
||||||
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
|
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
|
||||||
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
|
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
|
||||||
ary.reverse.sort { |a, b|
|
ary.reverse.sort! { |a, b|
|
||||||
compare_numerically a, b
|
compare_numerically a, b
|
||||||
}.should eq ary
|
}.should eq ary
|
||||||
end
|
end
|
||||||
@ -29,7 +29,7 @@ describe "compare_numerically" do
|
|||||||
# https://github.com/hkalexling/Mango/issues/22
|
# https://github.com/hkalexling/Mango/issues/22
|
||||||
it "handles numbers larger than Int32" do
|
it "handles numbers larger than Int32" do
|
||||||
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
|
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
|
||||||
ary.reverse.sort { |a, b|
|
ary.reverse.sort! { |a, b|
|
||||||
compare_numerically a, b
|
compare_numerically a, b
|
||||||
}.should eq ary
|
}.should eq ary
|
||||||
end
|
end
|
||||||
@ -56,7 +56,7 @@ describe "chapter_sort" do
|
|||||||
it "sorts correctly" do
|
it "sorts correctly" do
|
||||||
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
|
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
|
||||||
sorter = ChapterSorter.new ary
|
sorter = ChapterSorter.new ary
|
||||||
ary.reverse.sort do |a, b|
|
ary.reverse.sort! do |a, b|
|
||||||
sorter.compare a, b
|
sorter.compare a, b
|
||||||
end.should eq ary
|
end.should eq ary
|
||||||
end
|
end
|
||||||
|
@ -20,7 +20,6 @@ class Config
|
|||||||
property plugin_path : String = File.expand_path "~/mango/plugins",
|
property plugin_path : String = File.expand_path "~/mango/plugins",
|
||||||
home: true
|
home: true
|
||||||
property download_timeout_seconds : Int32 = 30
|
property download_timeout_seconds : Int32 = 30
|
||||||
property page_margin : Int32 = 30
|
|
||||||
property disable_login = false
|
property disable_login = false
|
||||||
property default_username = ""
|
property default_username = ""
|
||||||
property auth_proxy_header_name = ""
|
property auth_proxy_header_name = ""
|
||||||
|
@ -46,6 +46,18 @@ class Entry
|
|||||||
file.close
|
file.close
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def to_slim_json : String
|
||||||
|
JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
{% for str in ["zip_path", "title", "size", "id"] %}
|
||||||
|
json.field {{str}}, @{{str.id}}
|
||||||
|
{% end %}
|
||||||
|
json.field "title_id", @book.id
|
||||||
|
json.field "pages" { json.number @pages }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
def to_json(json : JSON::Builder)
|
||||||
json.object do
|
json.object do
|
||||||
{% for str in ["zip_path", "title", "size", "id"] %}
|
{% for str in ["zip_path", "title", "size", "id"] %}
|
||||||
@ -86,7 +98,7 @@ class Entry
|
|||||||
SUPPORTED_IMG_TYPES.includes? \
|
SUPPORTED_IMG_TYPES.includes? \
|
||||||
MIME.from_filename? e.filename
|
MIME.from_filename? e.filename
|
||||||
}
|
}
|
||||||
.sort { |a, b|
|
.sort! { |a, b|
|
||||||
compare_numerically a.filename, b.filename
|
compare_numerically a.filename, b.filename
|
||||||
}
|
}
|
||||||
yield file, entries
|
yield file, entries
|
||||||
|
@ -63,7 +63,22 @@ class Library
|
|||||||
end
|
end
|
||||||
|
|
||||||
def deep_titles
|
def deep_titles
|
||||||
titles + titles.map { |t| t.deep_titles }.flatten
|
titles + titles.flat_map &.deep_titles
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_slim_json : String
|
||||||
|
JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
json.field "dir", @dir
|
||||||
|
json.field "titles" do
|
||||||
|
json.array do
|
||||||
|
self.titles.each do |title|
|
||||||
|
json.raw title.to_slim_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
def to_json(json : JSON::Builder)
|
||||||
@ -98,7 +113,7 @@ class Library
|
|||||||
.select { |path| File.directory? path }
|
.select { |path| File.directory? path }
|
||||||
.map { |path| Title.new path, "" }
|
.map { |path| Title.new path, "" }
|
||||||
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
||||||
.sort { |a, b| a.title <=> b.title }
|
.sort! { |a, b| a.title <=> b.title }
|
||||||
.tap { |_| @title_ids.clear }
|
.tap { |_| @title_ids.clear }
|
||||||
.each do |title|
|
.each do |title|
|
||||||
@title_hash[title.id] = title
|
@title_hash[title.id] = title
|
||||||
@ -114,7 +129,7 @@ class Library
|
|||||||
|
|
||||||
def get_continue_reading_entries(username)
|
def get_continue_reading_entries(username)
|
||||||
cr_entries = deep_titles
|
cr_entries = deep_titles
|
||||||
.map { |t| t.get_last_read_entry username }
|
.map(&.get_last_read_entry username)
|
||||||
# Select elements with type `Entry` from the array and ignore all `Nil`s
|
# Select elements with type `Entry` from the array and ignore all `Nil`s
|
||||||
.select(Entry)[0...ENTRIES_IN_HOME_SECTIONS]
|
.select(Entry)[0...ENTRIES_IN_HOME_SECTIONS]
|
||||||
.map { |e|
|
.map { |e|
|
||||||
@ -150,14 +165,14 @@ class Library
|
|||||||
recently_added = [] of RA
|
recently_added = [] of RA
|
||||||
last_date_added = nil
|
last_date_added = nil
|
||||||
|
|
||||||
titles.map { |t| t.deep_entries_with_date_added }.flatten
|
titles.flat_map(&.deep_entries_with_date_added)
|
||||||
.select { |e| e[:date_added] > 1.month.ago }
|
.select(&.[:date_added].> 1.month.ago)
|
||||||
.sort { |a, b| b[:date_added] <=> a[:date_added] }
|
.sort! { |a, b| b[:date_added] <=> a[:date_added] }
|
||||||
.each do |e|
|
.each do |e|
|
||||||
break if recently_added.size > 12
|
break if recently_added.size > 12
|
||||||
last = recently_added.last?
|
last = recently_added.last?
|
||||||
if last && e[:entry].book.id == last[:entry].book.id &&
|
if last && e[:entry].book.id == last[:entry].book.id &&
|
||||||
(e[:date_added] - last_date_added.not_nil!).duration < 1.day
|
(e[:date_added] - last_date_added.not_nil!).abs < 1.day
|
||||||
# A NamedTuple is immutable, so we have to cast it to a Hash first
|
# A NamedTuple is immutable, so we have to cast it to a Hash first
|
||||||
last_hash = last.to_h
|
last_hash = last.to_h
|
||||||
count = last_hash[:grouped_count].as(Int32)
|
count = last_hash[:grouped_count].as(Int32)
|
||||||
@ -188,9 +203,9 @@ class Library
|
|||||||
# If we use `deep_titles`, the start reading section might include `Vol. 2`
|
# If we use `deep_titles`, the start reading section might include `Vol. 2`
|
||||||
# when the user hasn't started `Vol. 1` yet
|
# when the user hasn't started `Vol. 1` yet
|
||||||
titles
|
titles
|
||||||
.select { |t| t.load_percentage(username) == 0 }
|
.select(&.load_percentage(username).== 0)
|
||||||
.sample(ENTRIES_IN_HOME_SECTIONS)
|
.sample(ENTRIES_IN_HOME_SECTIONS)
|
||||||
.shuffle
|
.shuffle!
|
||||||
end
|
end
|
||||||
|
|
||||||
def thumbnail_generation_progress
|
def thumbnail_generation_progress
|
||||||
@ -205,7 +220,7 @@ class Library
|
|||||||
end
|
end
|
||||||
|
|
||||||
Logger.info "Starting thumbnail generation"
|
Logger.info "Starting thumbnail generation"
|
||||||
entries = deep_titles.map(&.deep_entries).flatten.reject &.err_msg
|
entries = deep_titles.flat_map(&.deep_entries).reject &.err_msg
|
||||||
@entries_count = entries.size
|
@entries_count = entries.size
|
||||||
@thumbnails_count = 0
|
@thumbnails_count = 0
|
||||||
|
|
||||||
|
@ -44,19 +44,54 @@ class Title
|
|||||||
|
|
||||||
mtimes = [@mtime]
|
mtimes = [@mtime]
|
||||||
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
|
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
|
||||||
mtimes += @entries.map { |e| e.mtime }
|
mtimes += @entries.map &.mtime
|
||||||
@mtime = mtimes.max
|
@mtime = mtimes.max
|
||||||
|
|
||||||
@title_ids.sort! do |a, b|
|
@title_ids.sort! do |a, b|
|
||||||
compare_numerically Library.default.title_hash[a].title,
|
compare_numerically Library.default.title_hash[a].title,
|
||||||
Library.default.title_hash[b].title
|
Library.default.title_hash[b].title
|
||||||
end
|
end
|
||||||
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
sorter = ChapterSorter.new @entries.map &.title
|
||||||
@entries.sort! do |a, b|
|
@entries.sort! do |a, b|
|
||||||
sorter.compare a.title, b.title
|
sorter.compare a.title, b.title
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def to_slim_json : String
|
||||||
|
JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
{% for str in ["dir", "title", "id"] %}
|
||||||
|
json.field {{str}}, @{{str.id}}
|
||||||
|
{% end %}
|
||||||
|
json.field "signature" { json.number @signature }
|
||||||
|
json.field "titles" do
|
||||||
|
json.array do
|
||||||
|
self.titles.each do |title|
|
||||||
|
json.raw title.to_slim_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
json.field "entries" do
|
||||||
|
json.array do
|
||||||
|
@entries.each do |entry|
|
||||||
|
json.raw entry.to_slim_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
json.field "parents" do
|
||||||
|
json.array do
|
||||||
|
self.parents.each do |title|
|
||||||
|
json.object do
|
||||||
|
json.field "title", title.title
|
||||||
|
json.field "id", title.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
def to_json(json : JSON::Builder)
|
||||||
json.object do
|
json.object do
|
||||||
{% for str in ["dir", "title", "id"] %}
|
{% for str in ["dir", "title", "id"] %}
|
||||||
@ -92,12 +127,12 @@ class Title
|
|||||||
# Get all entries, including entries in nested titles
|
# Get all entries, including entries in nested titles
|
||||||
def deep_entries
|
def deep_entries
|
||||||
return @entries if title_ids.empty?
|
return @entries if title_ids.empty?
|
||||||
@entries + titles.map { |t| t.deep_entries }.flatten
|
@entries + titles.flat_map &.deep_entries
|
||||||
end
|
end
|
||||||
|
|
||||||
def deep_titles
|
def deep_titles
|
||||||
return [] of Title if titles.empty?
|
return [] of Title if titles.empty?
|
||||||
titles + titles.map { |t| t.deep_titles }.flatten
|
titles + titles.flat_map &.deep_titles
|
||||||
end
|
end
|
||||||
|
|
||||||
def parents
|
def parents
|
||||||
@ -138,7 +173,7 @@ class Title
|
|||||||
end
|
end
|
||||||
|
|
||||||
def get_entry(eid)
|
def get_entry(eid)
|
||||||
@entries.find { |e| e.id == eid }
|
@entries.find &.id.== eid
|
||||||
end
|
end
|
||||||
|
|
||||||
def display_name
|
def display_name
|
||||||
@ -217,29 +252,23 @@ class Title
|
|||||||
@entries.each do |e|
|
@entries.each do |e|
|
||||||
e.save_progress username, e.pages
|
e.save_progress username, e.pages
|
||||||
end
|
end
|
||||||
titles.each do |t|
|
titles.each &.read_all username
|
||||||
t.read_all username
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Set the reading progress of all entries and nested libraries to 0%
|
# Set the reading progress of all entries and nested libraries to 0%
|
||||||
def unread_all(username)
|
def unread_all(username)
|
||||||
@entries.each do |e|
|
@entries.each &.save_progress(username, 0)
|
||||||
e.save_progress username, 0
|
titles.each &.unread_all username
|
||||||
end
|
|
||||||
titles.each do |t|
|
|
||||||
t.unread_all username
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def deep_read_page_count(username) : Int32
|
def deep_read_page_count(username) : Int32
|
||||||
load_progress_for_all_entries(username).sum +
|
load_progress_for_all_entries(username).sum +
|
||||||
titles.map { |t| t.deep_read_page_count username }.flatten.sum
|
titles.flat_map(&.deep_read_page_count username).sum
|
||||||
end
|
end
|
||||||
|
|
||||||
def deep_total_page_count : Int32
|
def deep_total_page_count : Int32
|
||||||
entries.map { |e| e.pages }.sum +
|
entries.sum(&.pages) +
|
||||||
titles.map { |t| t.deep_total_page_count }.flatten.sum
|
titles.flat_map(&.deep_total_page_count).sum
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_percentage(username)
|
def load_percentage(username)
|
||||||
@ -311,13 +340,13 @@ class Title
|
|||||||
ary = @entries.zip(percentage_ary)
|
ary = @entries.zip(percentage_ary)
|
||||||
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
|
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
|
||||||
compare_numerically a_tp[0].title, b_tp[0].title }
|
compare_numerically a_tp[0].title, b_tp[0].title }
|
||||||
.map { |tp| tp[0] }
|
.map &.[0]
|
||||||
else
|
else
|
||||||
unless opt.method.auto?
|
unless opt.method.auto?
|
||||||
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||||
"Auto instead"
|
"Auto instead"
|
||||||
end
|
end
|
||||||
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
sorter = ChapterSorter.new @entries.map &.title
|
||||||
ary = @entries.sort do |a, b|
|
ary = @entries.sort do |a, b|
|
||||||
sorter.compare(a.title, b.title).or \
|
sorter.compare(a.title, b.title).or \
|
||||||
compare_numerically a.title, b.title
|
compare_numerically a.title, b.title
|
||||||
@ -383,13 +412,13 @@ class Title
|
|||||||
{entry: e, date_added: da_ary[i]}
|
{entry: e, date_added: da_ary[i]}
|
||||||
end
|
end
|
||||||
return zip if title_ids.empty?
|
return zip if title_ids.empty?
|
||||||
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten
|
zip + titles.flat_map &.deep_entries_with_date_added
|
||||||
end
|
end
|
||||||
|
|
||||||
def bulk_progress(action, ids : Array(String), username)
|
def bulk_progress(action, ids : Array(String), username)
|
||||||
selected_entries = ids
|
selected_entries = ids
|
||||||
.map { |id|
|
.map { |id|
|
||||||
@entries.find { |e| e.id == id }
|
@entries.find &.id.==(id)
|
||||||
}
|
}
|
||||||
.select(Entry)
|
.select(Entry)
|
||||||
|
|
||||||
|
@ -34,7 +34,11 @@ class Logger
|
|||||||
end
|
end
|
||||||
|
|
||||||
@backend.formatter = Log::Formatter.new &format_proc
|
@backend.formatter = Log::Formatter.new &format_proc
|
||||||
Log.setup @@severity, @backend
|
|
||||||
|
Log.setup do |c|
|
||||||
|
c.bind "*", @@severity, @backend
|
||||||
|
c.bind "db.*", :error, @backend
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.get_severity(level = "") : Log::Severity
|
def self.get_severity(level = "") : Log::Severity
|
||||||
|
@ -1,169 +0,0 @@
|
|||||||
require "mangadex"
|
|
||||||
require "compress/zip"
|
|
||||||
require "../rename"
|
|
||||||
require "./ext"
|
|
||||||
|
|
||||||
module MangaDex
|
|
||||||
class PageJob
|
|
||||||
property success = false
|
|
||||||
property url : String
|
|
||||||
property filename : String
|
|
||||||
property writer : Compress::Zip::Writer
|
|
||||||
property tries_remaning : Int32
|
|
||||||
|
|
||||||
def initialize(@url, @filename, @writer, @tries_remaning)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Downloader < Queue::Downloader
|
|
||||||
@wait_seconds : Int32 = Config.current.mangadex["download_wait_seconds"]
|
|
||||||
.to_i32
|
|
||||||
@retries : Int32 = Config.current.mangadex["download_retries"].to_i32
|
|
||||||
|
|
||||||
use_default
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
@client = Client.from_config
|
|
||||||
super
|
|
||||||
end
|
|
||||||
|
|
||||||
def pop : Queue::Job?
|
|
||||||
job = nil
|
|
||||||
MainFiber.run do
|
|
||||||
DB.open "sqlite3://#{@queue.path}" do |db|
|
|
||||||
begin
|
|
||||||
db.query_one "select * from queue where id not like '%-%' " \
|
|
||||||
"and (status = 0 or status = 1) " \
|
|
||||||
"order by time limit 1" do |res|
|
|
||||||
job = Queue::Job.from_query_result res
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
job
|
|
||||||
end
|
|
||||||
|
|
||||||
private def download(job : Queue::Job)
|
|
||||||
@downloading = true
|
|
||||||
@queue.set_status Queue::JobStatus::Downloading, job
|
|
||||||
begin
|
|
||||||
chapter = @client.chapter job.id
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
@queue.set_status Queue::JobStatus::Error, job
|
|
||||||
unless e.message.nil?
|
|
||||||
@queue.add_message e.message.not_nil!, job
|
|
||||||
end
|
|
||||||
@downloading = false
|
|
||||||
return
|
|
||||||
end
|
|
||||||
@queue.set_pages chapter.pages.size, job
|
|
||||||
lib_dir = @library_path
|
|
||||||
rename_rule = Rename::Rule.new \
|
|
||||||
Config.current.mangadex["manga_rename_rule"].to_s
|
|
||||||
manga_dir = File.join lib_dir, chapter.manga.rename rename_rule
|
|
||||||
unless File.exists? manga_dir
|
|
||||||
Dir.mkdir_p manga_dir
|
|
||||||
end
|
|
||||||
zip_path = File.join manga_dir, "#{job.title}.cbz.part"
|
|
||||||
|
|
||||||
# Find the number of digits needed to store the number of pages
|
|
||||||
len = Math.log10(chapter.pages.size).to_i + 1
|
|
||||||
|
|
||||||
writer = Compress::Zip::Writer.new zip_path
|
|
||||||
# Create a buffered channel. It works as an FIFO queue
|
|
||||||
channel = Channel(PageJob).new chapter.pages.size
|
|
||||||
spawn do
|
|
||||||
chapter.pages.each_with_index do |url, i|
|
|
||||||
fn = Path.new(URI.parse(url).path).basename
|
|
||||||
ext = File.extname fn
|
|
||||||
fn = "#{i.to_s.rjust len, '0'}#{ext}"
|
|
||||||
page_job = PageJob.new url, fn, writer, @retries
|
|
||||||
Logger.debug "Downloading #{url}"
|
|
||||||
loop do
|
|
||||||
sleep @wait_seconds.seconds
|
|
||||||
download_page page_job
|
|
||||||
break if page_job.success ||
|
|
||||||
page_job.tries_remaning <= 0
|
|
||||||
page_job.tries_remaning -= 1
|
|
||||||
Logger.warn "Failed to download page #{url}. " \
|
|
||||||
"Retrying... Remaining retries: " \
|
|
||||||
"#{page_job.tries_remaning}"
|
|
||||||
end
|
|
||||||
|
|
||||||
channel.send page_job
|
|
||||||
break unless @queue.exists? job
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
spawn do
|
|
||||||
page_jobs = [] of PageJob
|
|
||||||
chapter.pages.size.times do
|
|
||||||
page_job = channel.receive
|
|
||||||
|
|
||||||
break unless @queue.exists? job
|
|
||||||
|
|
||||||
Logger.debug "[#{page_job.success ? "success" : "failed"}] " \
|
|
||||||
"#{page_job.url}"
|
|
||||||
page_jobs << page_job
|
|
||||||
if page_job.success
|
|
||||||
@queue.add_success job
|
|
||||||
else
|
|
||||||
@queue.add_fail job
|
|
||||||
msg = "Failed to download page #{page_job.url}"
|
|
||||||
@queue.add_message msg, job
|
|
||||||
Logger.error msg
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
unless @queue.exists? job
|
|
||||||
Logger.debug "Download cancelled"
|
|
||||||
@downloading = false
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
fail_count = page_jobs.count { |j| !j.success }
|
|
||||||
Logger.debug "Download completed. " \
|
|
||||||
"#{fail_count}/#{page_jobs.size} failed"
|
|
||||||
writer.close
|
|
||||||
filename = File.join File.dirname(zip_path), File.basename(zip_path,
|
|
||||||
".part")
|
|
||||||
File.rename zip_path, filename
|
|
||||||
Logger.debug "cbz File created at #{filename}"
|
|
||||||
|
|
||||||
zip_exception = validate_archive filename
|
|
||||||
if !zip_exception.nil?
|
|
||||||
@queue.add_message "The downloaded archive is corrupted. " \
|
|
||||||
"Error: #{zip_exception}", job
|
|
||||||
@queue.set_status Queue::JobStatus::Error, job
|
|
||||||
elsif fail_count > 0
|
|
||||||
@queue.set_status Queue::JobStatus::MissingPages, job
|
|
||||||
else
|
|
||||||
@queue.set_status Queue::JobStatus::Completed, job
|
|
||||||
end
|
|
||||||
@downloading = false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private def download_page(job : PageJob)
|
|
||||||
Logger.debug "downloading #{job.url}"
|
|
||||||
headers = HTTP::Headers{
|
|
||||||
"User-agent" => "Mangadex.cr",
|
|
||||||
}
|
|
||||||
begin
|
|
||||||
HTTP::Client.get job.url, headers do |res|
|
|
||||||
unless res.success?
|
|
||||||
raise "Failed to download page #{job.url}. " \
|
|
||||||
"[#{res.status_code}] #{res.status_message}"
|
|
||||||
end
|
|
||||||
job.writer.add job.filename, res.body_io
|
|
||||||
end
|
|
||||||
job.success = true
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
job.success = false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
@ -1,60 +0,0 @@
|
|||||||
private macro properties_to_hash(names)
|
|
||||||
{
|
|
||||||
{% for name in names %}
|
|
||||||
"{{name.id}}" => {{name.id}}.to_s,
|
|
||||||
{% end %}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Monkey-patch the structures in the `mangadex` shard to suit our needs
|
|
||||||
module MangaDex
|
|
||||||
struct Client
|
|
||||||
@@group_cache = {} of String => Group
|
|
||||||
|
|
||||||
def self.from_config : Client
|
|
||||||
self.new base_url: Config.current.mangadex["base_url"].to_s,
|
|
||||||
api_url: Config.current.mangadex["api_url"].to_s
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
struct Manga
|
|
||||||
def rename(rule : Rename::Rule)
|
|
||||||
rule.render properties_to_hash %w(id title author artist)
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_info_json
|
|
||||||
hash = JSON.parse(to_json).as_h
|
|
||||||
_chapters = chapters.map do |c|
|
|
||||||
JSON.parse c.to_info_json
|
|
||||||
end
|
|
||||||
hash["chapters"] = JSON::Any.new _chapters
|
|
||||||
hash.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
struct Chapter
|
|
||||||
def rename(rule : Rename::Rule)
|
|
||||||
hash = properties_to_hash %w(id title volume chapter lang_code language)
|
|
||||||
hash["groups"] = groups.map(&.name).join ","
|
|
||||||
rule.render hash
|
|
||||||
end
|
|
||||||
|
|
||||||
def full_title
|
|
||||||
rule = Rename::Rule.new \
|
|
||||||
Config.current.mangadex["chapter_rename_rule"].to_s
|
|
||||||
rename rule
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_info_json
|
|
||||||
hash = JSON.parse(to_json).as_h
|
|
||||||
hash["language"] = JSON::Any.new language
|
|
||||||
_groups = {} of String => JSON::Any
|
|
||||||
groups.each do |g|
|
|
||||||
_groups[g.name] = JSON::Any.new g.id
|
|
||||||
end
|
|
||||||
hash["groups"] = JSON::Any.new _groups
|
|
||||||
hash["full_title"] = JSON::Any.new full_title
|
|
||||||
hash.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
@ -2,13 +2,12 @@ require "./config"
|
|||||||
require "./queue"
|
require "./queue"
|
||||||
require "./server"
|
require "./server"
|
||||||
require "./main_fiber"
|
require "./main_fiber"
|
||||||
require "./mangadex/*"
|
|
||||||
require "./plugin/*"
|
require "./plugin/*"
|
||||||
require "option_parser"
|
require "option_parser"
|
||||||
require "clim"
|
require "clim"
|
||||||
require "tallboy"
|
require "tallboy"
|
||||||
|
|
||||||
MANGO_VERSION = "0.21.0"
|
MANGO_VERSION = "0.23.0"
|
||||||
|
|
||||||
# From http://www.network-science.de/ascii/
|
# From http://www.network-science.de/ascii/
|
||||||
BANNER = %{
|
BANNER = %{
|
||||||
@ -59,7 +58,6 @@ class CLI < Clim
|
|||||||
Storage.default
|
Storage.default
|
||||||
Queue.default
|
Queue.default
|
||||||
Library.default
|
Library.default
|
||||||
MangaDex::Downloader.default
|
|
||||||
Plugin::Downloader.default
|
Plugin::Downloader.default
|
||||||
|
|
||||||
spawn do
|
spawn do
|
||||||
|
@ -117,7 +117,7 @@ class Plugin
|
|||||||
def initialize(id : String)
|
def initialize(id : String)
|
||||||
Plugin.build_info_ary
|
Plugin.build_info_ary
|
||||||
|
|
||||||
@info = @@info_ary.find { |i| i.id == id }
|
@info = @@info_ary.find &.id.== id
|
||||||
if @info.nil?
|
if @info.nil?
|
||||||
raise Error.new "Plugin with ID #{id} not found"
|
raise Error.new "Plugin with ID #{id} not found"
|
||||||
end
|
end
|
||||||
|
@ -303,12 +303,12 @@ class Queue
|
|||||||
end
|
end
|
||||||
|
|
||||||
def pause
|
def pause
|
||||||
@downloaders.each { |d| d.stopped = true }
|
@downloaders.each &.stopped=(true)
|
||||||
@paused = true
|
@paused = true
|
||||||
end
|
end
|
||||||
|
|
||||||
def resume
|
def resume
|
||||||
@downloaders.each { |d| d.stopped = false }
|
@downloaders.each &.stopped=(false)
|
||||||
@paused = false
|
@paused = false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -35,15 +35,15 @@ module Rename
|
|||||||
|
|
||||||
class Group < Base(Pattern | String)
|
class Group < Base(Pattern | String)
|
||||||
def render(hash : VHash)
|
def render(hash : VHash)
|
||||||
return "" if @ary.select(&.is_a? Pattern)
|
return "" if @ary.select(Pattern)
|
||||||
.any? &.as(Pattern).render(hash).empty?
|
.any? &.as(Pattern).render(hash).empty?
|
||||||
@ary.map do |e|
|
@ary.join do |e|
|
||||||
if e.is_a? Pattern
|
if e.is_a? Pattern
|
||||||
e.render hash
|
e.render hash
|
||||||
else
|
else
|
||||||
e
|
e
|
||||||
end
|
end
|
||||||
end.join
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -129,13 +129,13 @@ module Rename
|
|||||||
end
|
end
|
||||||
|
|
||||||
def render(hash : VHash)
|
def render(hash : VHash)
|
||||||
str = @ary.map do |e|
|
str = @ary.join do |e|
|
||||||
if e.is_a? String
|
if e.is_a? String
|
||||||
e
|
e
|
||||||
else
|
else
|
||||||
e.render hash
|
e.render hash
|
||||||
end
|
end
|
||||||
end.join.strip
|
end.strip
|
||||||
post_process str
|
post_process str
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -73,9 +73,5 @@ struct AdminRouter
|
|||||||
get "/admin/missing" do |env|
|
get "/admin/missing" do |env|
|
||||||
layout "missing-items"
|
layout "missing-items"
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/admin/mangadex" do |env|
|
|
||||||
layout "mangadex"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
require "../mangadex/*"
|
|
||||||
require "../upload"
|
require "../upload"
|
||||||
require "koa"
|
require "koa"
|
||||||
|
require "digest"
|
||||||
|
|
||||||
struct APIRouter
|
struct APIRouter
|
||||||
@@api_json : String?
|
@@api_json : String?
|
||||||
@ -56,31 +56,20 @@ struct APIRouter
|
|||||||
"error" => String?,
|
"error" => String?,
|
||||||
}
|
}
|
||||||
|
|
||||||
Koa.schema("mdChapter", {
|
|
||||||
"id" => Int64,
|
|
||||||
"group" => {} of String => String,
|
|
||||||
}.merge(s %w(title volume chapter language full_title time
|
|
||||||
manga_title manga_id)),
|
|
||||||
desc: "A MangaDex chapter")
|
|
||||||
|
|
||||||
Koa.schema "mdManga", {
|
|
||||||
"id" => Int64,
|
|
||||||
"chapters" => ["mdChapter"],
|
|
||||||
}.merge(s %w(title description author artist cover_url)),
|
|
||||||
desc: "A MangaDex manga"
|
|
||||||
|
|
||||||
Koa.describe "Returns a page in a manga entry"
|
Koa.describe "Returns a page in a manga entry"
|
||||||
Koa.path "tid", desc: "Title ID"
|
Koa.path "tid", desc: "Title ID"
|
||||||
Koa.path "eid", desc: "Entry ID"
|
Koa.path "eid", desc: "Entry ID"
|
||||||
Koa.path "page", schema: Int32, desc: "The page number to return (starts from 1)"
|
Koa.path "page", schema: Int32, desc: "The page number to return (starts from 1)"
|
||||||
Koa.response 200, schema: Bytes, media_type: "image/*"
|
Koa.response 200, schema: Bytes, media_type: "image/*"
|
||||||
Koa.response 500, "Page not found or not readable"
|
Koa.response 500, "Page not found or not readable"
|
||||||
|
Koa.response 304, "Page not modified (only available when `If-None-Match` is set)"
|
||||||
Koa.tag "reader"
|
Koa.tag "reader"
|
||||||
get "/api/page/:tid/:eid/:page" do |env|
|
get "/api/page/:tid/:eid/:page" do |env|
|
||||||
begin
|
begin
|
||||||
tid = env.params.url["tid"]
|
tid = env.params.url["tid"]
|
||||||
eid = env.params.url["eid"]
|
eid = env.params.url["eid"]
|
||||||
page = env.params.url["page"].to_i
|
page = env.params.url["page"].to_i
|
||||||
|
prev_e_tag = env.request.headers["If-None-Match"]?
|
||||||
|
|
||||||
title = Library.default.get_title tid
|
title = Library.default.get_title tid
|
||||||
raise "Title ID `#{tid}` not found" if title.nil?
|
raise "Title ID `#{tid}` not found" if title.nil?
|
||||||
@ -90,7 +79,15 @@ struct APIRouter
|
|||||||
raise "Failed to load page #{page} of " \
|
raise "Failed to load page #{page} of " \
|
||||||
"`#{title.title}/#{entry.title}`" if img.nil?
|
"`#{title.title}/#{entry.title}`" if img.nil?
|
||||||
|
|
||||||
send_img env, img
|
e_tag = Digest::SHA1.hexdigest img.data
|
||||||
|
if prev_e_tag == e_tag
|
||||||
|
env.response.status_code = 304
|
||||||
|
""
|
||||||
|
else
|
||||||
|
env.response.headers["ETag"] = e_tag
|
||||||
|
env.response.headers["Cache-Control"] = "public, max-age=86400"
|
||||||
|
send_img env, img
|
||||||
|
end
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
@ -102,12 +99,14 @@ struct APIRouter
|
|||||||
Koa.path "tid", desc: "Title ID"
|
Koa.path "tid", desc: "Title ID"
|
||||||
Koa.path "eid", desc: "Entry ID"
|
Koa.path "eid", desc: "Entry ID"
|
||||||
Koa.response 200, schema: Bytes, media_type: "image/*"
|
Koa.response 200, schema: Bytes, media_type: "image/*"
|
||||||
|
Koa.response 304, "Page not modified (only available when `If-None-Match` is set)"
|
||||||
Koa.response 500, "Page not found or not readable"
|
Koa.response 500, "Page not found or not readable"
|
||||||
Koa.tag "library"
|
Koa.tag "library"
|
||||||
get "/api/cover/:tid/:eid" do |env|
|
get "/api/cover/:tid/:eid" do |env|
|
||||||
begin
|
begin
|
||||||
tid = env.params.url["tid"]
|
tid = env.params.url["tid"]
|
||||||
eid = env.params.url["eid"]
|
eid = env.params.url["eid"]
|
||||||
|
prev_e_tag = env.request.headers["If-None-Match"]?
|
||||||
|
|
||||||
title = Library.default.get_title tid
|
title = Library.default.get_title tid
|
||||||
raise "Title ID `#{tid}` not found" if title.nil?
|
raise "Title ID `#{tid}` not found" if title.nil?
|
||||||
@ -118,7 +117,14 @@ struct APIRouter
|
|||||||
raise "Failed to get cover of `#{title.title}/#{entry.title}`" \
|
raise "Failed to get cover of `#{title.title}/#{entry.title}`" \
|
||||||
if img.nil?
|
if img.nil?
|
||||||
|
|
||||||
send_img env, img
|
e_tag = Digest::SHA1.hexdigest img.data
|
||||||
|
if prev_e_tag == e_tag
|
||||||
|
env.response.status_code = 304
|
||||||
|
""
|
||||||
|
else
|
||||||
|
env.response.headers["ETag"] = e_tag
|
||||||
|
send_img env, img
|
||||||
|
end
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
@ -126,8 +132,11 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Returns the book with title `tid`"
|
Koa.describe "Returns the book with title `tid`", <<-MD
|
||||||
|
Supply the `tid` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
||||||
|
MD
|
||||||
Koa.path "tid", desc: "Title ID"
|
Koa.path "tid", desc: "Title ID"
|
||||||
|
Koa.query "slim"
|
||||||
Koa.response 200, schema: "title"
|
Koa.response 200, schema: "title"
|
||||||
Koa.response 404, "Title not found"
|
Koa.response 404, "Title not found"
|
||||||
Koa.tag "library"
|
Koa.tag "library"
|
||||||
@ -137,7 +146,11 @@ struct APIRouter
|
|||||||
title = Library.default.get_title tid
|
title = Library.default.get_title tid
|
||||||
raise "Title ID `#{tid}` not found" if title.nil?
|
raise "Title ID `#{tid}` not found" if title.nil?
|
||||||
|
|
||||||
send_json env, title.to_json
|
if env.params.query["slim"]?
|
||||||
|
send_json env, title.to_slim_json
|
||||||
|
else
|
||||||
|
send_json env, title.to_json
|
||||||
|
end
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
@ -145,14 +158,21 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Returns the entire library with all titles and entries"
|
Koa.describe "Returns the entire library with all titles and entries", <<-MD
|
||||||
|
Supply the `tid` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
||||||
|
MD
|
||||||
|
Koa.query "slim"
|
||||||
Koa.response 200, schema: {
|
Koa.response 200, schema: {
|
||||||
"dir" => String,
|
"dir" => String,
|
||||||
"titles" => ["title"],
|
"titles" => ["title"],
|
||||||
}
|
}
|
||||||
Koa.tag "library"
|
Koa.tag "library"
|
||||||
get "/api/library" do |env|
|
get "/api/library" do |env|
|
||||||
send_json env, Library.default.to_json
|
if env.params.query["slim"]?
|
||||||
|
send_json env, Library.default.to_slim_json
|
||||||
|
else
|
||||||
|
send_json env, Library.default.to_json
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Triggers a library scan"
|
Koa.describe "Triggers a library scan"
|
||||||
@ -309,64 +329,12 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Returns a MangaDex manga identified by `id`", <<-MD
|
|
||||||
On error, returns a JSON that contains the error message in the `error` field.
|
|
||||||
MD
|
|
||||||
Koa.tags ["admin", "mangadex"]
|
|
||||||
Koa.path "id", desc: "A MangaDex manga ID"
|
|
||||||
Koa.response 200, schema: "mdManga"
|
|
||||||
get "/api/admin/mangadex/manga/:id" do |env|
|
|
||||||
begin
|
|
||||||
id = env.params.url["id"]
|
|
||||||
manga = MangaDex::Client.from_config.manga id
|
|
||||||
send_json env, manga.to_info_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {"error" => e.message}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Adds a list of MangaDex chapters to the download queue", <<-MD
|
|
||||||
On error, returns a JSON that contains the error message in the `error` field.
|
|
||||||
MD
|
|
||||||
Koa.tags ["admin", "mangadex", "downloader"]
|
|
||||||
Koa.body schema: {
|
|
||||||
"chapters" => ["mdChapter"],
|
|
||||||
}
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Int32,
|
|
||||||
"fail" => Int32,
|
|
||||||
}
|
|
||||||
post "/api/admin/mangadex/download" do |env|
|
|
||||||
begin
|
|
||||||
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
|
|
||||||
jobs = chapters.map { |chapter|
|
|
||||||
Queue::Job.new(
|
|
||||||
chapter["id"].as_i64.to_s,
|
|
||||||
chapter["mangaId"].as_i64.to_s,
|
|
||||||
chapter["full_title"].as_s,
|
|
||||||
chapter["mangaTitle"].as_s,
|
|
||||||
Queue::JobStatus::Pending,
|
|
||||||
Time.unix chapter["timestamp"].as_i64
|
|
||||||
)
|
|
||||||
}
|
|
||||||
inserted_count = Queue.default.push jobs
|
|
||||||
send_json env, {
|
|
||||||
"success": inserted_count,
|
|
||||||
"fail": jobs.size - inserted_count,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {"error" => e.message}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
ws "/api/admin/mangadex/queue" do |socket, env|
|
ws "/api/admin/mangadex/queue" do |socket, env|
|
||||||
interval_raw = env.params.query["interval"]?
|
interval_raw = env.params.query["interval"]?
|
||||||
interval = (interval_raw.to_i? if interval_raw) || 5
|
interval = (interval_raw.to_i? if interval_raw) || 5
|
||||||
loop do
|
loop do
|
||||||
socket.send({
|
socket.send({
|
||||||
"jobs" => Queue.default.get_all,
|
"jobs" => Queue.default.get_all.reverse,
|
||||||
"paused" => Queue.default.paused?,
|
"paused" => Queue.default.paused?,
|
||||||
}.to_json)
|
}.to_json)
|
||||||
sleep interval.seconds
|
sleep interval.seconds
|
||||||
@ -390,13 +358,13 @@ struct APIRouter
|
|||||||
}
|
}
|
||||||
get "/api/admin/mangadex/queue" do |env|
|
get "/api/admin/mangadex/queue" do |env|
|
||||||
begin
|
begin
|
||||||
jobs = Queue.default.get_all
|
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"jobs" => jobs,
|
"jobs" => Queue.default.get_all.reverse,
|
||||||
"paused" => Queue.default.paused?,
|
"paused" => Queue.default.paused?,
|
||||||
"success" => true,
|
"success" => true,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@ -444,6 +412,7 @@ struct APIRouter
|
|||||||
|
|
||||||
send_json env, {"success" => true}.to_json
|
send_json env, {"success" => true}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@ -516,6 +485,7 @@ struct APIRouter
|
|||||||
|
|
||||||
raise "No part with name `file` found"
|
raise "No part with name `file` found"
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@ -551,6 +521,7 @@ struct APIRouter
|
|||||||
"title" => title,
|
"title" => title,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@ -594,6 +565,7 @@ struct APIRouter
|
|||||||
"fail": jobs.size - inserted_count,
|
"fail": jobs.size - inserted_count,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@ -612,25 +584,35 @@ struct APIRouter
|
|||||||
"width" => Int32,
|
"width" => Int32,
|
||||||
"height" => Int32,
|
"height" => Int32,
|
||||||
}],
|
}],
|
||||||
"margin" => Int32?,
|
|
||||||
}
|
}
|
||||||
|
Koa.response 304, "Not modified (only available when `If-None-Match` is set)"
|
||||||
get "/api/dimensions/:tid/:eid" do |env|
|
get "/api/dimensions/:tid/:eid" do |env|
|
||||||
begin
|
begin
|
||||||
tid = env.params.url["tid"]
|
tid = env.params.url["tid"]
|
||||||
eid = env.params.url["eid"]
|
eid = env.params.url["eid"]
|
||||||
|
prev_e_tag = env.request.headers["If-None-Match"]?
|
||||||
|
|
||||||
title = Library.default.get_title tid
|
title = Library.default.get_title tid
|
||||||
raise "Title ID `#{tid}` not found" if title.nil?
|
raise "Title ID `#{tid}` not found" if title.nil?
|
||||||
entry = title.get_entry eid
|
entry = title.get_entry eid
|
||||||
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
||||||
|
|
||||||
sizes = entry.page_dimensions
|
file_hash = Digest::SHA1.hexdigest (entry.zip_path + entry.mtime.to_s)
|
||||||
send_json env, {
|
e_tag = "W/#{file_hash}"
|
||||||
"success" => true,
|
if e_tag == prev_e_tag
|
||||||
"dimensions" => sizes,
|
env.response.status_code = 304
|
||||||
"margin" => Config.current.page_margin,
|
""
|
||||||
}.to_json
|
else
|
||||||
|
sizes = entry.page_dimensions
|
||||||
|
env.response.headers["ETag"] = e_tag
|
||||||
|
env.response.headers["Cache-Control"] = "public, max-age=86400"
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"dimensions" => sizes,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@ -770,6 +752,7 @@ struct APIRouter
|
|||||||
"titles" => Storage.default.missing_titles,
|
"titles" => Storage.default.missing_titles,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@ -796,6 +779,7 @@ struct APIRouter
|
|||||||
"entries" => Storage.default.missing_entries,
|
"entries" => Storage.default.missing_entries,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@ -814,6 +798,7 @@ struct APIRouter
|
|||||||
"error" => nil,
|
"error" => nil,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@ -832,6 +817,7 @@ struct APIRouter
|
|||||||
"error" => nil,
|
"error" => nil,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@ -853,6 +839,7 @@ struct APIRouter
|
|||||||
"error" => nil,
|
"error" => nil,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@ -873,114 +860,6 @@ struct APIRouter
|
|||||||
"success" => true,
|
"success" => true,
|
||||||
"error" => nil,
|
"error" => nil,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Logs the current user into their MangaDex account", <<-MD
|
|
||||||
If successful, returns the expiration date (as a unix timestamp) of the newly created token.
|
|
||||||
MD
|
|
||||||
Koa.body schema: {
|
|
||||||
"username" => String,
|
|
||||||
"password" => String,
|
|
||||||
}
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"expires" => Int64?,
|
|
||||||
}
|
|
||||||
Koa.tags ["admin", "mangadex", "users"]
|
|
||||||
post "/api/admin/mangadex/login" do |env|
|
|
||||||
begin
|
|
||||||
username = env.params.json["username"].as String
|
|
||||||
password = env.params.json["password"].as String
|
|
||||||
mango_username = get_username env
|
|
||||||
|
|
||||||
client = MangaDex::Client.from_config
|
|
||||||
client.auth username, password
|
|
||||||
|
|
||||||
Storage.default.save_md_token mango_username, client.token.not_nil!,
|
|
||||||
client.token_expires
|
|
||||||
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
"expires" => client.token_expires.to_unix,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Returns the expiration date (as a unix timestamp) of the mangadex token if it exists"
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"expires" => Int64?,
|
|
||||||
}
|
|
||||||
Koa.tags ["admin", "mangadex", "users"]
|
|
||||||
get "/api/admin/mangadex/expires" do |env|
|
|
||||||
begin
|
|
||||||
username = get_username env
|
|
||||||
_, expires = Storage.default.get_md_token username
|
|
||||||
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
"expires" => expires.try &.to_unix,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Searches MangaDex for manga matching `query`", <<-MD
|
|
||||||
Returns an empty list if the current user hasn't logged in to MangaDex.
|
|
||||||
MD
|
|
||||||
Koa.query "query"
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"manga?" => [{
|
|
||||||
"id" => Int64,
|
|
||||||
"title" => String,
|
|
||||||
"description" => String,
|
|
||||||
"mainCover" => String,
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
Koa.tags ["admin", "mangadex"]
|
|
||||||
get "/api/admin/mangadex/search" do |env|
|
|
||||||
begin
|
|
||||||
username = get_username env
|
|
||||||
token, expires = Storage.default.get_md_token username
|
|
||||||
|
|
||||||
unless expires && token
|
|
||||||
raise "No token found for user #{username}"
|
|
||||||
end
|
|
||||||
|
|
||||||
client = MangaDex::Client.from_config
|
|
||||||
client.token = token
|
|
||||||
client.token_expires = expires
|
|
||||||
|
|
||||||
query = env.params.query["query"]
|
|
||||||
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
"manga" => client.partial_search query,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
|
@ -30,7 +30,8 @@ struct MainRouter
|
|||||||
else
|
else
|
||||||
redirect env, "/"
|
redirect env, "/"
|
||||||
end
|
end
|
||||||
rescue
|
rescue e
|
||||||
|
Logger.error e
|
||||||
redirect env, "/login"
|
redirect env, "/login"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -71,11 +72,6 @@ struct MainRouter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/download" do |env|
|
|
||||||
mangadex_base_url = Config.current.mangadex["base_url"]
|
|
||||||
layout "download"
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/download/plugins" do |env|
|
get "/download/plugins" do |env|
|
||||||
begin
|
begin
|
||||||
id = env.params.query["plugin"]?
|
id = env.params.query["plugin"]?
|
||||||
@ -103,7 +99,7 @@ struct MainRouter
|
|||||||
recently_added = Library.default.get_recently_added_entries username
|
recently_added = Library.default.get_recently_added_entries username
|
||||||
start_reading = Library.default.get_start_reading_titles username
|
start_reading = Library.default.get_start_reading_titles username
|
||||||
titles = Library.default.titles
|
titles = Library.default.titles
|
||||||
new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
|
new_user = !titles.any? &.load_percentage(username).> 0
|
||||||
empty_library = titles.size == 0
|
empty_library = titles.size == 0
|
||||||
layout "home"
|
layout "home"
|
||||||
rescue e
|
rescue e
|
||||||
|
@ -445,7 +445,7 @@ class Storage
|
|||||||
Logger.debug "Marking #{trash_ids.size} entries as unavailable"
|
Logger.debug "Marking #{trash_ids.size} entries as unavailable"
|
||||||
end
|
end
|
||||||
db.exec "update ids set unavailable = 1 where id in " \
|
db.exec "update ids set unavailable = 1 where id in " \
|
||||||
"(#{trash_ids.map { |i| "'#{i}'" }.join ","})"
|
"(#{trash_ids.join "," { |i| "'#{i}'" }})"
|
||||||
|
|
||||||
# Detect dangling title IDs
|
# Detect dangling title IDs
|
||||||
trash_titles = [] of String
|
trash_titles = [] of String
|
||||||
@ -461,7 +461,7 @@ class Storage
|
|||||||
Logger.debug "Marking #{trash_titles.size} titles as unavailable"
|
Logger.debug "Marking #{trash_titles.size} titles as unavailable"
|
||||||
end
|
end
|
||||||
db.exec "update titles set unavailable = 1 where id in " \
|
db.exec "update titles set unavailable = 1 where id in " \
|
||||||
"(#{trash_titles.map { |i| "'#{i}'" }.join ","})"
|
"(#{trash_titles.join "," { |i| "'#{i}'" }})"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
83
src/subscription.cr
Normal file
83
src/subscription.cr
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
require "db"
|
||||||
|
require "json"
|
||||||
|
|
||||||
|
struct Subscription
|
||||||
|
include DB::Serializable
|
||||||
|
include JSON::Serializable
|
||||||
|
|
||||||
|
getter id : Int64 = 0
|
||||||
|
getter username : String
|
||||||
|
getter manga_id : Int64
|
||||||
|
property language : String?
|
||||||
|
property group_id : Int64?
|
||||||
|
property min_volume : Int64?
|
||||||
|
property max_volume : Int64?
|
||||||
|
property min_chapter : Int64?
|
||||||
|
property max_chapter : Int64?
|
||||||
|
@[DB::Field(key: "last_checked")]
|
||||||
|
@[JSON::Field(key: "last_checked")]
|
||||||
|
@raw_last_checked : Int64
|
||||||
|
@[DB::Field(key: "created_at")]
|
||||||
|
@[JSON::Field(key: "created_at")]
|
||||||
|
@raw_created_at : Int64
|
||||||
|
|
||||||
|
def last_checked : Time
|
||||||
|
Time.unix @raw_last_checked
|
||||||
|
end
|
||||||
|
|
||||||
|
def created_at : Time
|
||||||
|
Time.unix @raw_created_at
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(@manga_id, @username)
|
||||||
|
@raw_created_at = Time.utc.to_unix
|
||||||
|
@raw_last_checked = Time.utc.to_unix
|
||||||
|
end
|
||||||
|
|
||||||
|
private def in_range?(value : String, lowerbound : Int64?,
|
||||||
|
upperbound : Int64?) : Bool
|
||||||
|
lb = lowerbound.try &.to_f64
|
||||||
|
ub = upperbound.try &.to_f64
|
||||||
|
|
||||||
|
return true if lb.nil? && ub.nil?
|
||||||
|
|
||||||
|
v = value.to_f64?
|
||||||
|
return false unless v
|
||||||
|
|
||||||
|
if lb.nil?
|
||||||
|
v <= ub.not_nil!
|
||||||
|
elsif ub.nil?
|
||||||
|
v >= lb.not_nil!
|
||||||
|
else
|
||||||
|
v >= lb.not_nil! && v <= ub.not_nil!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def match?(chapter : MangaDex::Chapter) : Bool
|
||||||
|
if chapter.manga_id != manga_id ||
|
||||||
|
(language && chapter.language != language) ||
|
||||||
|
(group_id && !chapter.groups.map(&.id).includes? group_id)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
in_range?(chapter.volume, min_volume, max_volume) &&
|
||||||
|
in_range?(chapter.chapter, min_chapter, max_chapter)
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_for_updates : Int32
|
||||||
|
Logger.debug "Checking updates for subscription with ID #{id}"
|
||||||
|
jobs = [] of Queue::Job
|
||||||
|
get_client(username).user.updates_after last_checked do |chapter|
|
||||||
|
next unless match? chapter
|
||||||
|
jobs << chapter.to_job
|
||||||
|
end
|
||||||
|
Storage.default.update_subscription_last_checked id
|
||||||
|
count = Queue.default.push jobs
|
||||||
|
Logger.debug "#{count}/#{jobs.size} of updates added to queue"
|
||||||
|
count
|
||||||
|
rescue e
|
||||||
|
Logger.error "Error occurred when checking updates for " \
|
||||||
|
"subscription with ID #{id}. #{e}"
|
||||||
|
0
|
||||||
|
end
|
||||||
|
end
|
@ -73,7 +73,7 @@ class ChapterSorter
|
|||||||
.select do |key|
|
.select do |key|
|
||||||
keys[key].count >= str_ary.size / 2
|
keys[key].count >= str_ary.size / 2
|
||||||
end
|
end
|
||||||
.sort do |a_key, b_key|
|
.sort! do |a_key, b_key|
|
||||||
a = keys[a_key]
|
a = keys[a_key]
|
||||||
b = keys[b_key]
|
b = keys[b_key]
|
||||||
# Sort keys by the number of times they appear
|
# Sort keys by the number of times they appear
|
||||||
|
@ -11,7 +11,7 @@ end
|
|||||||
def split_by_alphanumeric(str)
|
def split_by_alphanumeric(str)
|
||||||
arr = [] of String
|
arr = [] of String
|
||||||
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
|
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
|
||||||
arr += match.captures.select { |s| s != "" }
|
arr += match.captures.select &.!= ""
|
||||||
end
|
end
|
||||||
arr
|
arr
|
||||||
end
|
end
|
||||||
|
@ -114,7 +114,7 @@ class String
|
|||||||
def components_similarity(other : String) : Float64
|
def components_similarity(other : String) : Float64
|
||||||
s, l = [self, other]
|
s, l = [self, other]
|
||||||
.map { |str| Path.new(str).parts }
|
.map { |str| Path.new(str).parts }
|
||||||
.sort_by &.size
|
.sort_by! &.size
|
||||||
|
|
||||||
match = s.reverse.zip(l.reverse).count { |a, b| a == b }
|
match = s.reverse.zip(l.reverse).count { |a, b| a == b }
|
||||||
match / s.size
|
match / s.size
|
||||||
|
@ -72,7 +72,7 @@ def redirect(env, path)
|
|||||||
end
|
end
|
||||||
|
|
||||||
def hash_to_query(hash)
|
def hash_to_query(hash)
|
||||||
hash.map { |k, v| "#{k}=#{v}" }.join("&")
|
hash.join "&" { |k, v| "#{k}=#{v}" }
|
||||||
end
|
end
|
||||||
|
|
||||||
def request_path_startswith(env, ary)
|
def request_path_startswith(env, ary)
|
||||||
|
@ -33,7 +33,6 @@
|
|||||||
<option>System</option>
|
<option>System</option>
|
||||||
</select>
|
</select>
|
||||||
</li>
|
</li>
|
||||||
<li><a class="uk-link-reset" href="<%= base_url %>admin/mangadex">Connect to MangaDex</a></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<hr class="uk-divider-icon">
|
<hr class="uk-divider-icon">
|
||||||
|
@ -49,11 +49,10 @@
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td x-text="`${job.plugin_id || ''}`"></td>
|
<td x-text="`${job.plugin_id || ''}`"></td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<a @click="jobAction('delete', $event)" uk-icon="trash"></a>
|
<a @click="jobAction('delete', $event)" uk-icon="trash" uk-tooltip="Delete"></a>
|
||||||
<template x-if="job.status_message.length > 0">
|
<template x-if="job.status_message.length > 0">
|
||||||
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
|
<a @click="jobAction('retry', $event)" uk-icon="refresh" uk-tooltip="Retry"></a>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -61,6 +60,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<%= render_component "moment" %>
|
<%= render_component "moment" %>
|
||||||
|
@ -1,89 +1,87 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
<%= render_component "head" %>
|
<%= render_component "head" %>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="uk-offcanvas-content">
|
<div class="uk-offcanvas-content">
|
||||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||||
<div id="mobile-nav" uk-offcanvas="overlay: true">
|
<div id="mobile-nav" uk-offcanvas="overlay: true">
|
||||||
<div class="uk-offcanvas-bar uk-flex uk-flex-column">
|
<div class="uk-offcanvas-bar uk-flex uk-flex-column">
|
||||||
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
|
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
|
||||||
<li><a href="<%= base_url %>">Home</a></li>
|
<li><a href="<%= base_url %>">Home</a></li>
|
||||||
<li><a href="<%= base_url %>library">Library</a></li>
|
<li><a href="<%= base_url %>library">Library</a></li>
|
||||||
<li><a href="<%= base_url %>tags">Tags</a></li>
|
<li><a href="<%= base_url %>tags">Tags</a></li>
|
||||||
<% if is_admin %>
|
<% if is_admin %>
|
||||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||||
<li class="uk-parent">
|
<li class="uk-parent">
|
||||||
<a href="#">Download</a>
|
<a href="#">Download</a>
|
||||||
<ul class="uk-nav-sub">
|
<ul class="uk-nav-sub">
|
||||||
<li><a href="<%= base_url %>download">MangaDex</a></li>
|
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
||||||
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
</ul>
|
||||||
</ul>
|
</li>
|
||||||
</li>
|
<% end %>
|
||||||
<% end %>
|
<hr uk-divider>
|
||||||
<hr uk-divider>
|
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
<li><a href="<%= base_url %>logout">Logout</a></li>
|
||||||
<li><a href="<%= base_url %>logout">Logout</a></li>
|
</ul>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-position-top">
|
</div>
|
||||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
</div>
|
||||||
<div class="uk-navbar-left uk-hidden@s">
|
<div class="uk-position-top">
|
||||||
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||||
</div>
|
<div class="uk-navbar-left uk-hidden@s">
|
||||||
<div class="uk-navbar-left uk-visible@s">
|
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
||||||
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png" style="width:90px;height:90px;"></a>
|
|
||||||
<ul class="uk-navbar-nav">
|
|
||||||
<li><a href="<%= base_url %>">Home</a></li>
|
|
||||||
<li><a href="<%= base_url %>library">Library</a></li>
|
|
||||||
<li><a href="<%= base_url %>tags">Tags</a></li>
|
|
||||||
<% if is_admin %>
|
|
||||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
|
||||||
<li>
|
|
||||||
<a href="#">Download</a>
|
|
||||||
<div class="uk-navbar-dropdown">
|
|
||||||
<ul class="uk-nav uk-navbar-dropdown-nav">
|
|
||||||
<li class="uk-nav-header">Source</li>
|
|
||||||
<li><a href="<%= base_url %>download">MangaDex</a></li>
|
|
||||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
|
||||||
<li class="uk-nav-divider"></li>
|
|
||||||
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="uk-navbar-right uk-visible@s">
|
|
||||||
<ul class="uk-navbar-nav">
|
|
||||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
|
||||||
<li><a href="<%= base_url %>logout">Logout</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-section uk-section-small">
|
<div class="uk-navbar-left uk-visible@s">
|
||||||
</div>
|
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png" style="width:90px;height:90px;"></a>
|
||||||
<div class="uk-section uk-section-small" style="position:relative;">
|
<ul class="uk-navbar-nav">
|
||||||
<div class="uk-container uk-container-small">
|
<li><a href="<%= base_url %>">Home</a></li>
|
||||||
<div id="alert"></div>
|
<li><a href="<%= base_url %>library">Library</a></li>
|
||||||
<%= content %>
|
<li><a href="<%= base_url %>tags">Tags</a></li>
|
||||||
<div class="uk-visible@m" id="totop-wrapper" x-data="{}" x-show="$('body').height() > 1.5 * $(window).height()">
|
<% if is_admin %>
|
||||||
<a href="#" uk-totop uk-scroll></a>
|
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||||
|
<li>
|
||||||
|
<a href="#">Download</a>
|
||||||
|
<div class="uk-navbar-dropdown">
|
||||||
|
<ul class="uk-nav uk-navbar-dropdown-nav">
|
||||||
|
<li class="uk-nav-header">Source</li>
|
||||||
|
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||||
|
<li class="uk-nav-divider"></li>
|
||||||
|
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<div class="uk-navbar-right uk-visible@s">
|
||||||
setTheme();
|
<ul class="uk-navbar-nav">
|
||||||
const base_url = "<%= base_url %>";
|
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||||
</script>
|
<li><a href="<%= base_url %>logout">Logout</a></li>
|
||||||
<%= render_component "uikit" %>
|
</ul>
|
||||||
<%= yield_content "script" %>
|
</div>
|
||||||
</body>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-section uk-section-small">
|
||||||
|
</div>
|
||||||
|
<div class="uk-section uk-section-small" style="position:relative;">
|
||||||
|
<div class="uk-container uk-container-small">
|
||||||
|
<div id="alert"></div>
|
||||||
|
<%= content %>
|
||||||
|
<div class="uk-visible@m" id="totop-wrapper" x-data="{}" x-show="$('body').height() > 1.5 * $(window).height()">
|
||||||
|
<a href="#" uk-totop uk-scroll></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
setTheme();
|
||||||
|
const base_url = "<%= base_url %>";
|
||||||
|
</script>
|
||||||
|
<%= render_component "uikit" %>
|
||||||
|
<%= yield_content "script" %>
|
||||||
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
@ -56,8 +56,10 @@
|
|||||||
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
||||||
<table class="uk-table uk-table-striped uk-overflow-auto tablesorter">
|
<div class="uk-overflow-auto">
|
||||||
</table>
|
<table class="uk-table uk-table-striped tablesorter">
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
@ -21,15 +21,15 @@
|
|||||||
<div
|
<div
|
||||||
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
|
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
|
||||||
<div x-show="!loading && mode === 'continuous'" x-cloak>
|
<div x-show="!loading && mode === 'continuous'" x-cloak>
|
||||||
<template x-for="item in items">
|
<template x-if="!loading && mode === 'continuous'" x-for="item in items">
|
||||||
<img
|
<img
|
||||||
uk-img
|
uk-img
|
||||||
:class="{'uk-align-center': true, 'spine': item.width < 50}"
|
:class="{'uk-align-center': true, 'spine': item.width < 50}"
|
||||||
:style="item.style"
|
|
||||||
:data-src="item.url"
|
:data-src="item.url"
|
||||||
:width="item.width"
|
:width="item.width"
|
||||||
:height="item.height"
|
:height="item.height"
|
||||||
:id="item.id"
|
:id="item.id"
|
||||||
|
:style="`margin-top:${margin}px; margin-bottom:${margin}px`"
|
||||||
@click="showControl($event)"
|
@click="showControl($event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@ -50,6 +50,9 @@
|
|||||||
width:${mode === 'width' ? '100vw' : 'auto'};
|
width:${mode === 'width' ? '100vw' : 'auto'};
|
||||||
height:${mode === 'height' ? '100vh' : 'auto'};
|
height:${mode === 'height' ? '100vh' : 'auto'};
|
||||||
margin-bottom:0;
|
margin-bottom:0;
|
||||||
|
max-width:100%;
|
||||||
|
max-height:100%;
|
||||||
|
object-fit: contain;
|
||||||
`" />
|
`" />
|
||||||
|
|
||||||
<div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false)"></div>
|
<div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false)"></div>
|
||||||
@ -80,6 +83,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="mode-select">Mode</label>
|
<label class="uk-form-label" for="mode-select">Mode</label>
|
||||||
<div class="uk-form-controls">
|
<div class="uk-form-controls">
|
||||||
@ -90,6 +94,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="uk-margin" x-show="mode === 'continuous'">
|
||||||
|
<label class="uk-form-label" for="margin-range" x-text="`Page Margin: ${margin}px`"></label>
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<input id="margin-range" class="uk-range" type="range" min="0" max="50" step="5" x-model="margin" @change="marginChanged()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="uk-margin uk-form-horizontal" x-show="mode !== 'continuous'">
|
||||||
|
<label class="uk-form-label" for="enable-flip-animation">Enable Flip Animation</label>
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<input id="enable-flip-animation" class="uk-checkbox" type="checkbox" x-model="enableFlipAnimation" @change="enableFlipAnimationChanged()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-margin uk-form-horizontal" x-show="mode !== 'continuous'">
|
||||||
|
<label class="uk-form-label" for="preload-lookahead" x-text="`Preload Image: ${preloadLookahead} page(s)`"></label>
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<input id="preload-lookahead" class="uk-range" type="range" min="0" max="5" step="1" x-model.number="preloadLookahead" @change="preloadLookaheadChanged()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr class="uk-divider-icon">
|
<hr class="uk-divider-icon">
|
||||||
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
@ -110,12 +134,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="uk-modal-footer uk-text-right">
|
<div class="uk-modal-footer uk-text-right">
|
||||||
<% if previous_entry_url %>
|
<% if previous_entry_url %>
|
||||||
<a class="uk-button uk-button-default uk-margin-small-right" href="<%= previous_entry_url %>">Previous Entry</a>
|
<a class="uk-button uk-button-default uk-margin-small-bottom uk-margin-small-right" href="<%= previous_entry_url %>">Previous Entry</a>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if next_entry_url %>
|
<% if next_entry_url %>
|
||||||
<a class="uk-button uk-button-default uk-margin-small-right" href="<%= next_entry_url %>">Next Entry</a>
|
<a class="uk-button uk-button-default uk-margin-small-bottom uk-margin-small-right" href="<%= next_entry_url %>">Next Entry</a>
|
||||||
<% end %>
|
<% end %>
|
||||||
<a class="uk-button uk-button-danger" href="<%= exit_url %>">Exit Reader</a>
|
<a class="uk-button uk-button-danger uk-margin-small-bottom uk-margin-small-right" href="<%= exit_url %>">Exit Reader</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
54
src/views/subscription.html.ecr
Normal file
54
src/views/subscription.html.ecr
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<h2 class="uk-title">MangaDex Subscription Manager</h2>
|
||||||
|
|
||||||
|
<div x-data="component()" x-init="init()">
|
||||||
|
<p x-show="available === false">The subscription manager uses a MangaDex API that requires authentication. Please <a href="<%= base_url %>admin/mangadex">connect to MangaDex</a> before using this feature.</p>
|
||||||
|
|
||||||
|
<p x-show="available && subscriptions.length === 0">No subscription found. Go to the <a href="<%= base_url %>download">MangaDex download page</a> and start subscribing.</p>
|
||||||
|
|
||||||
|
<template x-if="subscriptions.length > 0">
|
||||||
|
<div class="uk-overflow-auto">
|
||||||
|
<table class="uk-table uk-table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Manga ID</th>
|
||||||
|
<th>Language</th>
|
||||||
|
<th>Group ID</th>
|
||||||
|
<th>Volume Range</th>
|
||||||
|
<th>Chapter Range</th>
|
||||||
|
<th>Creator</th>
|
||||||
|
<th>Last Checked</th>
|
||||||
|
<th>Created At</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="sub in subscriptions" :key="sub">
|
||||||
|
<tr>
|
||||||
|
<td><a :href="`<%= mangadex_base_url %>/manga/${sub.manga_id}`" x-text="sub.manga_id"></a></td>
|
||||||
|
<td x-text="sub.language || 'All'"></td>
|
||||||
|
<td>
|
||||||
|
<a x-show="sub.group_id" :href="`<%= mangadex_base_url %>/group/${sub.group_id}`" x-text="sub.group_id"></a>
|
||||||
|
<span x-show="!sub.group_id">All</span>
|
||||||
|
</td>
|
||||||
|
<td x-text="formatRange(sub.min_volume, sub.max_volume)"></td>
|
||||||
|
<td x-text="formatRange(sub.min_chapter, sub.max_chapter)"></td>
|
||||||
|
<td x-text="sub.username"></td>
|
||||||
|
<td x-text="`${moment.unix(sub.last_checked).fromNow()}`"></td>
|
||||||
|
<td x-text="`${moment.unix(sub.created_at).fromNow()}`"></td>
|
||||||
|
<td :data-id="sub.id">
|
||||||
|
<a @click="check($event)" x-show="sub.username === '<%= username %>'" uk-icon="refresh" uk-tooltip="Check for updates"></a>
|
||||||
|
<a @click="rm($event)" x-show="sub.username === '<%= username %>'" uk-icon="trash" uk-tooltip="Delete"></a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% content_for "script" do %>
|
||||||
|
<%= render_component "moment" %>
|
||||||
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
|
<script src="<%= base_url %>js/subscription.js"></script>
|
||||||
|
<% end %>
|
Loading…
x
Reference in New Issue
Block a user