Compare commits

..

23 Commits

Author SHA1 Message Date
Leeingnyo 5a17ca07d8 Use yaml-static in Dockerfile 2021-08-18 19:42:32 +09:00
Alex Ling 46e6e41bfe Fix reader buttons stacking on mobile 2021-03-29 00:41:33 +00:00
Alex Ling c9f55e7a8e Use yaml-static 2021-03-28 12:49:50 +00:00
Alex Ling 741c3a4e20 Update config example in README 2021-03-28 11:56:06 +00:00
Alex Ling f6da20321d Bump version to 0.22.0 2021-03-28 11:49:49 +00:00
Alex Ling 2764e955b2 Show success alert on plugin download page 2021-03-15 17:07:15 +00:00
Alex Ling 00c15014a1 Document subscription APIs 2021-03-15 07:12:30 +00:00
Alex Ling c6fdbfd9fd Better format ranges on subscription manager page 2021-03-15 07:12:10 +00:00
Alex Ling e03bf32358 Show success alerts on the download page 2021-03-14 17:36:43 +00:00
Alex Ling bbf1520c73 Make in_range? private 2021-03-14 17:36:26 +00:00
Alex Ling 8950c3a1ed Fix downloader stuck on external chapters 2021-03-14 16:27:08 +00:00
Alex Ling 17837d8a29 Add tooltips to download manager 2021-03-14 16:03:37 +00:00
Alex Ling b4a69425c8 Reverse the queue on download manager 2021-03-14 16:01:29 +00:00
Alex Ling a612500b0f Subscription manager 2021-03-14 16:01:29 +00:00
Alex Ling 9bb7144479 Fix warning 2021-03-12 15:28:39 +00:00
Alex Ling ee52c52f46 Fix new linter errors 2021-03-12 15:03:12 +00:00
Alex Ling daec2bdac6 Update ameba 2021-03-12 14:06:20 +00:00
Alex Ling e9a490676b Update the mangadex shard 2021-03-12 13:59:11 +00:00
Alex Ling 757f7c8214 Upgrade Crystal to 0.36.1 2021-03-12 13:41:24 +00:00
Alex Ling eed1a9717e Merge branch 'master' into dev 2021-03-10 16:48:51 +00:00
Alex Ling 0b3e78bcb7 Merge branch 'rc/0.21.0' into dev 2021-03-09 16:45:26 +00:00
Alex Ling 6a275286ea Merge branch 'rc/0.21.0' into dev 2021-03-07 14:14:46 +00:00
Alex Ling d3f26ecbc9 Move the page margin config to frontend 2021-03-06 15:04:44 +00:00
39 changed files with 1065 additions and 415 deletions
+2 -2
View File
@@ -12,12 +12,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: crystallang/crystal:0.35.1-alpine image: crystallang/crystal:0.36.1-alpine
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Install dependencies - name: Install dependencies
run: apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static 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
+2 -2
View File
@@ -1,9 +1,9 @@
FROM crystallang/crystal:0.35.1-alpine AS builder FROM crystallang/crystal:0.36.1-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
+1 -1
View File
@@ -2,7 +2,7 @@ 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 0.36.1 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd .. RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd .. RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd .. RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
+1 -1
View File
@@ -2,7 +2,7 @@ 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 0.36.1 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd .. RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd .. RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd .. RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
+2 -1
View File
@@ -52,7 +52,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI ### CLI
``` ```
Mango - Manga Server and Web Reader. Version 0.21.0 Mango - Manga Server and Web Reader. Version 0.22.0
Usage: Usage:
@@ -99,6 +99,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
+31
View File
@@ -0,0 +1,31 @@
class CreateSubscription < MG::Base
def up : String
# We allow multiple subscriptions for the same manga.
# This can be useful for example when you want to download from multiple
# groups.
<<-SQL
CREATE TABLE subscription (
id INTEGER PRIMARY KEY,
manga_id INTEGER NOT NULL,
language TEXT,
group_id INTEGER,
min_volume INTEGER,
max_volume INTEGER,
min_chapter INTEGER,
max_chapter INTEGER,
last_checked INTEGER NOT NULL,
created_at INTEGER NOT NULL,
username TEXT NOT NULL,
FOREIGN KEY (username) REFERENCES users (username)
ON UPDATE CASCADE
ON DELETE CASCADE
);
SQL
end
def down : String
<<-SQL
DROP TABLE subscription;
SQL
end
end
+95 -3
View File
@@ -260,9 +260,7 @@ const downloadComponent = () => {
} }
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}`);
@@ -282,6 +280,100 @@ const downloadComponent = () => {
UIkit.modal($('#modal').get(0)).hide(); UIkit.modal($('#modal').get(0)).hide();
this.searchInput = id; this.searchInput = id;
this.search(); this.search();
},
subscribe(langConfirmed = false, groupConfirmed = false) {
const filters = {
manga: this.data.id,
language: this.langChoice === 'All' ? null : this.langChoice,
group: this.groupChoice === 'All' ? null : this.groupChoice,
volume: this.volumeRange === '' ? null : this.volumeRange,
chapter: this.chapterRange === '' ? null : this.chapterRange
};
// Get group ID
if (filters.group) {
this.data.chapters.forEach(chp => {
const gid = chp.groups[filters.group];
if (gid) {
filters.groupId = gid;
return;
}
});
}
// Parse range values
if (filters.volume) {
[filters.volumeMin, filters.volumeMax] = this.parseRange(filters.volume);
}
if (filters.chapter) {
[filters.chapterMin, filters.chapterMax] = this.parseRange(filters.chapter);
}
if (!filters.language && !langConfirmed) {
UIkit.modal.confirm('You didn\'t specify a language in the filtering rules. This might cause Mango to download chapters that are not in your preferred language. Are you sure you want to continue?', {
labels: {
ok: 'Yes',
cancel: 'Cancel'
}
}).then(() => {
this.subscribe(true, groupConfirmed);
});
return;
}
if (!filters.group && !groupConfirmed) {
UIkit.modal.confirm('You didn\'t specify a group in the filtering rules. This might cause Mango to download multiple versions of the same chapter. Are you sure you want to continue?', {
labels: {
ok: 'Yes',
cancel: 'Cancel'
}
}).then(() => {
this.subscribe(langConfirmed, true);
});
return;
}
const mangaURL = `${mangadex_base_url}/manga/${filters.manga}`;
console.log(filters);
UIkit.modal.confirm(`All <strong>FUTURE</strong> chapters matching the following filters will be downloaded:<br>
<ul>
<li>Manga ID: ${filters.manga}</li>
<li>Language: ${filters.language || 'all'}</li>
<li>Group: ${filters.group || 'all'}</li>
<li>Volume: ${filters.volume || 'all'}</li>
<li>Chapter: ${filters.chapter || 'all'}</li>
</ul>
<strong>IMPORTANT:</strong> Please make sure you are following the manga on MangaDex, otherwise Mango won't be able to receive any updates. To follow it, visit <a href="${mangaURL}">${mangaURL}</a> and click "Follow".
`, {
labels: {
ok: 'Confirm',
cancel: 'Cancel'
}
}).then(() => {
$.ajax({
type: 'POST',
url: `${base_url}api/admin/mangadex/subscriptions`,
data: JSON.stringify({
subscription: filters
}),
contentType: "application/json",
dataType: 'json'
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Failed to subscribe. Error: ${data.error}`);
return;
}
alert('success', `You've successfully subscribed to this manga! You can view and manage your subscriptions on the <a href="${base_url}download/subscription">subscription manager page</a>.`);
})
.fail((jqXHR, status) => {
alert('danger', `Failed to subscribe. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
});
} }
}; };
}; };
+1 -3
View File
@@ -126,9 +126,7 @@ const download = () => {
} }
const successCount = parseInt(data.success); const successCount = parseInt(data.success);
const failCount = parseInt(data.fail); const failCount = parseInt(data.fail);
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => { alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`);
window.location.href = base_url + 'admin/downloads';
});
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
+11 -1
View File
@@ -10,6 +10,7 @@ const readerComponent = () => {
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,
/** /**
* Initialize the component by fetching the page dimensions * Initialize the component by fetching the page dimensions
@@ -27,7 +28,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 +47,11 @@ 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;
}
}) })
.catch(e => { .catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`; const errMsg = `Failed to get the page dimensions. ${e}`;
@@ -277,6 +282,11 @@ 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);
} }
}; };
} }
+82
View File
@@ -0,0 +1,82 @@
const component = () => {
return {
available: undefined,
subscriptions: [],
init() {
$.getJSON(`${base_url}api/admin/mangadex/expires`)
.done((data) => {
if (data.error) {
alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
return;
}
this.available = Boolean(data.expires && data.expires > Math.floor(Date.now() / 1000));
if (this.available) this.getSubscriptions();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
},
getSubscriptions() {
$.getJSON(`${base_url}api/admin/mangadex/subscriptions`)
.done(data => {
if (data.error) {
alert('danger', 'Failed to get subscriptions. Error: ' + data.error);
return;
}
this.subscriptions = data.subscriptions;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to get subscriptions. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
},
rm(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({
type: 'DELETE',
url: `${base_url}api/admin/mangadex/subscriptions/${id}`,
contentType: 'application/json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to delete subscription. Error: ${data.error}`);
}
this.getSubscriptions();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to delete subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
check(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({
type: 'POST',
url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`,
contentType: 'application/json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to check subscription. Error: ${data.error}`);
return;
}
alert('success', 'Mango is now checking the subscription for updates. This might take a while, but you can safely leave the page.');
})
.fail((jqXHR, status) => {
alert('danger', `Failed to check subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
formatRange(min, max) {
if (!isNaN(min) && isNaN(max)) return `≥ ${min}`;
if (isNaN(min) && !isNaN(max)) return `≤ ${max}`;
if (isNaN(min) && isNaN(max)) return 'All';
if (min === max) return `= ${min}`;
return `${min} - ${max}`;
}
};
};
+4 -4
View File
@@ -2,7 +2,7 @@ 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.0
archive: archive:
git: https://github.com/hkalexling/archive.cr.git git: https://github.com/hkalexling/archive.cr.git
@@ -30,7 +30,7 @@ shards:
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
@@ -42,7 +42,7 @@ shards:
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: 0.13.0
kilt: kilt:
git: https://github.com/jeromegn/kilt.git git: https://github.com/jeromegn/kilt.git
@@ -54,7 +54,7 @@ shards:
mangadex: mangadex:
git: https://github.com/hkalexling/mangadex.git git: https://github.com/hkalexling/mangadex.git
version: 0.8.0+git.commit.24e6fb51afd043721139355854e305b43bf98c43 version: 0.11.0+git.commit.f5b0d64fbb138879fb9228b6e9ff34ec97c3e824
mg: mg:
git: https://github.com/hkalexling/mg.git git: https://github.com/hkalexling/mg.git
+2 -2
View File
@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.21.0 version: 0.22.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: 0.36.1
license: MIT license: MIT
+1 -3
View File
@@ -8,9 +8,7 @@ describe Storage do
end end
it "deletes user" do it "deletes user" do
with_storage do |storage| with_storage &.delete_user "admin"
storage.delete_user "admin"
end
end end
it "creates new user" do it "creates new user" do
+3 -3
View File
@@ -21,7 +21,7 @@ describe "compare_numerically" do
it "sorts like the stack exchange post" do it "sorts like the stack exchange post" do
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2", ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"] "text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
ary.reverse.sort { |a, b| ary.reverse.sort! { |a, b|
compare_numerically a, b compare_numerically a, b
}.should eq ary }.should eq ary
end end
@@ -29,7 +29,7 @@ describe "compare_numerically" do
# https://github.com/hkalexling/Mango/issues/22 # https://github.com/hkalexling/Mango/issues/22
it "handles numbers larger than Int32" do it "handles numbers larger than Int32" do
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"] ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
ary.reverse.sort { |a, b| ary.reverse.sort! { |a, b|
compare_numerically a, b compare_numerically a, b
}.should eq ary }.should eq ary
end end
@@ -56,7 +56,7 @@ describe "chapter_sort" do
it "sorts correctly" do it "sorts correctly" do
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"] ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
sorter = ChapterSorter.new ary sorter = ChapterSorter.new ary
ary.reverse.sort do |a, b| ary.reverse.sort! do |a, b|
sorter.compare a, b sorter.compare a, b
end.should eq ary end.should eq ary
end end
+4 -3
View File
@@ -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 = ""
@@ -34,8 +33,10 @@ class Config
"download_retries" => 4, "download_retries" => 4,
"download_queue_db_path" => File.expand_path("~/mango/queue.db", "download_queue_db_path" => File.expand_path("~/mango/queue.db",
home: true), home: true),
"chapter_rename_rule" => "[Vol.{volume} ][Ch.{chapter} ]{title|id}", "chapter_rename_rule" => "[Vol.{volume} ]" \
"manga_rename_rule" => "{title}", "[Ch.{chapter} ]{title|id}",
"manga_rename_rule" => "{title}",
"subscription_update_interval_hours" => 24,
} }
@@singlet : Config? @@singlet : Config?
+1 -1
View File
@@ -86,7 +86,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
+29 -10
View File
@@ -42,6 +42,25 @@ class Library
end end
end end
end end
subscription_interval = Config.current
.mangadex["subscription_update_interval_hours"].as Int32
unless subscription_interval < 1
spawn do
loop do
subscriptions = Storage.default.subscriptions
Logger.info "Checking MangaDex for updates on " \
"#{subscriptions.size} subscriptions"
added_count = 0
subscriptions.each do |sub|
added_count += sub.check_for_updates
end
Logger.info "Subscription update completed. Added #{added_count} " \
"chapters to the download queue"
sleep subscription_interval.hours
end
end
end
end end
def titles def titles
@@ -63,7 +82,7 @@ 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 end
def to_json(json : JSON::Builder) def to_json(json : JSON::Builder)
@@ -98,7 +117,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 +133,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 +169,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 +207,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 +224,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
+15 -21
View File
@@ -44,14 +44,14 @@ 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
@@ -92,12 +92,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 +138,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 +217,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 +305,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 +377,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)
+8 -5
View File
@@ -49,6 +49,9 @@ module MangaDex
@queue.set_status Queue::JobStatus::Downloading, job @queue.set_status Queue::JobStatus::Downloading, job
begin begin
chapter = @client.chapter job.id chapter = @client.chapter job.id
# We must put the `.pages` call in a rescue block to handle external
# chapters.
pages = chapter.pages
rescue e rescue e
Logger.error e Logger.error e
@queue.set_status Queue::JobStatus::Error, job @queue.set_status Queue::JobStatus::Error, job
@@ -58,7 +61,7 @@ module MangaDex
@downloading = false @downloading = false
return return
end end
@queue.set_pages chapter.pages.size, job @queue.set_pages pages.size, job
lib_dir = @library_path lib_dir = @library_path
rename_rule = Rename::Rule.new \ rename_rule = Rename::Rule.new \
Config.current.mangadex["manga_rename_rule"].to_s Config.current.mangadex["manga_rename_rule"].to_s
@@ -69,13 +72,13 @@ module MangaDex
zip_path = File.join manga_dir, "#{job.title}.cbz.part" zip_path = File.join manga_dir, "#{job.title}.cbz.part"
# Find the number of digits needed to store the number of pages # Find the number of digits needed to store the number of pages
len = Math.log10(chapter.pages.size).to_i + 1 len = Math.log10(pages.size).to_i + 1
writer = Compress::Zip::Writer.new zip_path writer = Compress::Zip::Writer.new zip_path
# Create a buffered channel. It works as an FIFO queue # Create a buffered channel. It works as an FIFO queue
channel = Channel(PageJob).new chapter.pages.size channel = Channel(PageJob).new pages.size
spawn do spawn do
chapter.pages.each_with_index do |url, i| pages.each_with_index do |url, i|
fn = Path.new(URI.parse(url).path).basename fn = Path.new(URI.parse(url).path).basename
ext = File.extname fn ext = File.extname fn
fn = "#{i.to_s.rjust len, '0'}#{ext}" fn = "#{i.to_s.rjust len, '0'}#{ext}"
@@ -99,7 +102,7 @@ module MangaDex
spawn do spawn do
page_jobs = [] of PageJob page_jobs = [] of PageJob
chapter.pages.size.times do pages.size.times do
page_job = channel.receive page_job = channel.receive
break unless @queue.exists? job break unless @queue.exists? job
+35 -1
View File
@@ -35,7 +35,7 @@ module MangaDex
struct Chapter struct Chapter
def rename(rule : Rename::Rule) def rename(rule : Rename::Rule)
hash = properties_to_hash %w(id title volume chapter lang_code language) hash = properties_to_hash %w(id title volume chapter lang_code language)
hash["groups"] = groups.map(&.name).join "," hash["groups"] = groups.join(",", &.name)
rule.render hash rule.render hash
end end
@@ -56,5 +56,39 @@ module MangaDex
hash["full_title"] = JSON::Any.new full_title hash["full_title"] = JSON::Any.new full_title
hash.to_json hash.to_json
end end
# We don't need to rename the manga title here. It will be renamed in
# src/mangadex/downloader.cr
def to_job : Queue::Job
Queue::Job.new(
id.to_s,
manga_id.to_s,
full_title,
manga_title,
Queue::JobStatus::Pending,
Time.unix timestamp
)
end
end
struct User
def updates_after(time : Time, &block : Chapter ->)
page = 1
stopped = false
until stopped
chapters = followed_updates(page: page).chapters
return if chapters.empty?
chapters.each do |c|
if time > Time.unix c.timestamp
stopped = true
break
end
yield c
end
page += 1
# Let's not DDOS MangaDex :)
sleep 5.seconds
end
end
end end
end end
+1 -1
View File
@@ -8,7 +8,7 @@ require "option_parser"
require "clim" require "clim"
require "tallboy" require "tallboy"
MANGO_VERSION = "0.21.0" MANGO_VERSION = "0.22.0"
# From http://www.network-science.de/ascii/ # From http://www.network-science.de/ascii/
BANNER = %{ BANNER = %{
+1 -1
View File
@@ -117,7 +117,7 @@ class Plugin
def initialize(id : String) def initialize(id : String)
Plugin.build_info_ary Plugin.build_info_ary
@info = @@info_ary.find { |i| i.id == id } @info = @@info_ary.find &.id.== id
if @info.nil? if @info.nil?
raise Error.new "Plugin with ID #{id} not found" raise Error.new "Plugin with ID #{id} not found"
end end
+2 -2
View File
@@ -303,12 +303,12 @@ class Queue
end end
def pause def pause
@downloaders.each { |d| d.stopped = true } @downloaders.each &.stopped=(true)
@paused = true @paused = true
end end
def resume def resume
@downloaders.each { |d| d.stopped = false } @downloaders.each &.stopped=(false)
@paused = false @paused = false
end end
+5 -5
View File
@@ -35,15 +35,15 @@ module Rename
class Group < Base(Pattern | String) class Group < Base(Pattern | String)
def render(hash : VHash) def render(hash : VHash)
return "" if @ary.select(&.is_a? Pattern) return "" if @ary.select(Pattern)
.any? &.as(Pattern).render(hash).empty? .any? &.as(Pattern).render(hash).empty?
@ary.map do |e| @ary.join do |e|
if e.is_a? Pattern if e.is_a? Pattern
e.render hash e.render hash
else else
e e
end end
end.join end
end end
end end
@@ -129,13 +129,13 @@ module Rename
end end
def render(hash : VHash) def render(hash : VHash)
str = @ary.map do |e| str = @ary.join do |e|
if e.is_a? String if e.is_a? String
e e
else else
e.render hash e.render hash
end end
end.join.strip end.strip
post_process str post_process str
end end
+151 -18
View File
@@ -339,7 +339,7 @@ struct APIRouter
} }
post "/api/admin/mangadex/download" do |env| post "/api/admin/mangadex/download" do |env|
begin begin
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h } chapters = env.params.json["chapters"].as(Array).map &.as_h
jobs = chapters.map { |chapter| jobs = chapters.map { |chapter|
Queue::Job.new( Queue::Job.new(
chapter["id"].as_i64.to_s, chapter["id"].as_i64.to_s,
@@ -366,7 +366,7 @@ struct APIRouter
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 +390,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 +444,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 +517,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 +553,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 +597,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,7 +616,6 @@ struct APIRouter
"width" => Int32, "width" => Int32,
"height" => Int32, "height" => Int32,
}], }],
"margin" => Int32?,
} }
get "/api/dimensions/:tid/:eid" do |env| get "/api/dimensions/:tid/:eid" do |env|
begin begin
@@ -628,9 +631,9 @@ struct APIRouter
send_json env, { send_json env, {
"success" => true, "success" => true,
"dimensions" => sizes, "dimensions" => sizes,
"margin" => Config.current.page_margin,
}.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,
@@ -770,6 +773,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 +800,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 +819,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 +838,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 +860,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,
@@ -874,6 +882,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,
@@ -963,23 +972,147 @@ struct APIRouter
Koa.tags ["admin", "mangadex"] Koa.tags ["admin", "mangadex"]
get "/api/admin/mangadex/search" do |env| get "/api/admin/mangadex/search" do |env|
begin 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"] query = env.params.query["query"]
send_json env, { send_json env, {
"success" => true, "success" => true,
"error" => nil, "error" => nil,
"manga" => client.partial_search query, "manga" => get_client(env).partial_search query,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Lists all MangaDex subscriptions"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"subscriptions?" => [{
"id" => Int64,
"username" => String,
"manga_id" => Int64,
"language" => String?,
"group_id" => Int64?,
"min_volume" => Int64?,
"max_volume" => Int64?,
"min_chapter" => Int64?,
"max_chapter" => Int64?,
"last_checked" => Int64,
"created_at" => Int64,
}],
}
Koa.tags ["admin", "mangadex", "subscriptions"]
get "/api/admin/mangadex/subscriptions" do |env|
begin
send_json env, {
"success" => true,
"error" => nil,
"subscriptions" => Storage.default.subscriptions,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Creates a new MangaDex subscription"
Koa.body schema: {
"subscription" => {
"manga" => Int64,
"language" => String?,
"groupId" => Int64?,
"volumeMin" => Int64?,
"volumeMax" => Int64?,
"chapterMin" => Int64?,
"chapterMax" => Int64?,
},
}
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
}
Koa.tags ["admin", "mangadex", "subscriptions"]
post "/api/admin/mangadex/subscriptions" do |env|
begin
json = env.params.json["subscription"].as Hash(String, JSON::Any)
sub = Subscription.new json["manga"].as_i64, get_username env
sub.language = json["language"]?.try &.as_s?
sub.group_id = json["groupId"]?.try &.as_i64?
sub.min_volume = json["volumeMin"]?.try &.as_i64?
sub.max_volume = json["volumeMax"]?.try &.as_i64?
sub.min_chapter = json["chapterMin"]?.try &.as_i64?
sub.max_chapter = json["chapterMax"]?.try &.as_i64?
Storage.default.save_subscription sub
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Deletes a MangaDex subscription identified by `id`", <<-MD
Does nothing if the subscription was not created by the current user.
MD
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
}
Koa.tags ["admin", "mangadex", "subscriptions"]
delete "/api/admin/mangadex/subscriptions/:id" do |env|
begin
id = env.params.url["id"].to_i64
Storage.default.delete_subscription id, get_username env
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Triggers an update for a MangaDex subscription identified by `id`", <<-MD
Does nothing if the subscription was not created by the current user.
MD
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
}
Koa.tags ["admin", "mangadex", "subscriptions"]
post "/api/admin/mangadex/subscriptions/check/:id" do |env|
begin
id = env.params.url["id"].to_i64
username = get_username env
sub = Storage.default.get_subscription id, username
unless sub
raise "Subscription with id #{id} not found under user #{username}"
end
spawn do
sub.check_for_updates
end
send_json env, {
"success" => true,
"error" => nil,
}.to_json }.to_json
rescue e rescue e
Logger.error e Logger.error e
+7 -1
View File
@@ -95,6 +95,12 @@ struct MainRouter
end end
end end
get "/download/subscription" do |env|
mangadex_base_url = Config.current.mangadex["base_url"]
username = get_username env
layout "subscription"
end
get "/" do |env| get "/" do |env|
begin begin
username = get_username env username = get_username env
@@ -103,7 +109,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
+70 -2
View File
@@ -5,6 +5,7 @@ require "base64"
require "./util/*" require "./util/*"
require "mg" require "mg"
require "../migration/*" require "../migration/*"
require "./subscription"
def hash_password(pw) def hash_password(pw)
Crypto::Bcrypt::Password.create(pw).to_s Crypto::Bcrypt::Password.create(pw).to_s
@@ -14,6 +15,9 @@ def verify_password(hash, pw)
(Crypto::Bcrypt::Password.new hash).verify pw (Crypto::Bcrypt::Password.new hash).verify pw
end end
SUB_ATTR = %w(manga_id language group_id min_volume max_volume min_chapter
max_chapter username)
class Storage class Storage
@@insert_entry_ids = [] of IDTuple @@insert_entry_ids = [] of IDTuple
@@insert_title_ids = [] of IDTuple @@insert_title_ids = [] of IDTuple
@@ -445,7 +449,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 +465,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
@@ -545,6 +549,70 @@ class Storage
{token, expires} {token, expires}
end end
def save_subscription(sub : Subscription)
MainFiber.run do
get_db do |db|
{% begin %}
db.exec "insert into subscription (#{SUB_ATTR.join ","}, " \
"last_checked, created_at) values " \
"(#{Array.new(SUB_ATTR.size + 2, "?").join ","})",
{% for type in SUB_ATTR %}
sub.{{type.id}},
{% end %}
sub.last_checked.to_unix, sub.created_at.to_unix
{% end %}
end
end
end
def subscriptions : Array(Subscription)
subs = [] of Subscription
MainFiber.run do
get_db do |db|
db.query "select * from subscription" do |rs|
subs += Subscription.from_rs rs
end
end
end
subs
end
def delete_subscription(id : Int64, username : String)
MainFiber.run do
get_db do |db|
db.exec "delete from subscription where id = (?) and username = (?)",
id, username
end
end
end
def get_subscription(id : Int64, username : String) : Subscription?
sub = nil
MainFiber.run do
get_db do |db|
db.query "select * from subscription where id = (?) and " \
"username = (?) limit 1", id, username do |rs|
sub = Subscription.from_rs(rs).first?
end
end
end
sub
end
def update_subscription_last_checked(id : Int64? = nil)
MainFiber.run do
get_db do |db|
if id
db.exec "update subscription set last_checked = (?) where id = (?)",
Time.utc.to_unix, id
else
db.exec "update subscription set last_checked = (?)",
Time.utc.to_unix
end
end
end
end
def close def close
MainFiber.run do MainFiber.run do
unless @db.nil? unless @db.nil?
+83
View File
@@ -0,0 +1,83 @@
require "db"
require "json"
struct Subscription
include DB::Serializable
include JSON::Serializable
getter id : Int64 = 0
getter username : String
getter manga_id : Int64
property language : String?
property group_id : Int64?
property min_volume : Int64?
property max_volume : Int64?
property min_chapter : Int64?
property max_chapter : Int64?
@[DB::Field(key: "last_checked")]
@[JSON::Field(key: "last_checked")]
@raw_last_checked : Int64
@[DB::Field(key: "created_at")]
@[JSON::Field(key: "created_at")]
@raw_created_at : Int64
def last_checked : Time
Time.unix @raw_last_checked
end
def created_at : Time
Time.unix @raw_created_at
end
def initialize(@manga_id, @username)
@raw_created_at = Time.utc.to_unix
@raw_last_checked = Time.utc.to_unix
end
private def in_range?(value : String, lowerbound : Int64?,
upperbound : Int64?) : Bool
lb = lowerbound.try &.to_f64
ub = upperbound.try &.to_f64
return true if lb.nil? && ub.nil?
v = value.to_f64?
return false unless v
if lb.nil?
v <= ub.not_nil!
elsif ub.nil?
v >= lb.not_nil!
else
v >= lb.not_nil! && v <= ub.not_nil!
end
end
def match?(chapter : MangaDex::Chapter) : Bool
if chapter.manga_id != manga_id ||
(language && chapter.language != language) ||
(group_id && !chapter.groups.map(&.id).includes? group_id)
return false
end
in_range?(chapter.volume, min_volume, max_volume) &&
in_range?(chapter.chapter, min_chapter, max_chapter)
end
def check_for_updates : Int32
Logger.debug "Checking updates for subscription with ID #{id}"
jobs = [] of Queue::Job
get_client(username).user.updates_after last_checked do |chapter|
next unless match? chapter
jobs << chapter.to_job
end
Storage.default.update_subscription_last_checked id
count = Queue.default.push jobs
Logger.debug "#{count}/#{jobs.size} of updates added to queue"
count
rescue e
Logger.error "Error occurred when checking updates for " \
"subscription with ID #{id}. #{e}"
0
end
end
+1 -1
View File
@@ -73,7 +73,7 @@ class ChapterSorter
.select do |key| .select do |key|
keys[key].count >= str_ary.size / 2 keys[key].count >= str_ary.size / 2
end end
.sort do |a_key, b_key| .sort! do |a_key, b_key|
a = keys[a_key] a = keys[a_key]
b = keys[b_key] b = keys[b_key]
# Sort keys by the number of times they appear # Sort keys by the number of times they appear
+1 -1
View File
@@ -11,7 +11,7 @@ end
def split_by_alphanumeric(str) def split_by_alphanumeric(str)
arr = [] of String arr = [] of String
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match| str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
arr += match.captures.select { |s| s != "" } arr += match.captures.select &.!= ""
end end
arr arr
end end
+1 -1
View File
@@ -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
+20 -1
View File
@@ -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)
@@ -107,6 +107,25 @@ macro get_sort_opt
end end
end end
# Returns an authorized client
def get_client(username : String) : MangaDex::Client
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
client
end
def get_client(env) : MangaDex::Client
get_client get_username env
end
module HTTP module HTTP
class Client class Client
private def self.exec(uri : URI, tls : TLSContext = nil) private def self.exec(uri : URI, tls : TLSContext = nil)
+56 -54
View File
@@ -5,61 +5,63 @@
<button class="uk-button uk-button-default" @click="load()" :disabled="loading">Refresh Queue</button> <button class="uk-button uk-button-default" @click="load()" :disabled="loading">Refresh Queue</button>
<button class="uk-button uk-button-default" x-show="paused !== undefined" x-text="paused ? 'Resume Download' : 'Pause Download'" @click="toggle()" :disabled="toggling"></button> <button class="uk-button uk-button-default" x-show="paused !== undefined" x-text="paused ? 'Resume Download' : 'Pause Download'" @click="toggle()" :disabled="toggling"></button>
</div> </div>
<table class="uk-table uk-table-striped uk-overflow-auto"> <div class="uk-overflow-auto">
<thead> <table class="uk-table uk-table-striped">
<tr> <thead>
<th>Chapter</th> <tr>
<th>Manga</th> <th>Chapter</th>
<th>Progress</th> <th>Manga</th>
<th>Time</th> <th>Progress</th>
<th>Status</th> <th>Time</th>
<th>Plugin</th> <th>Status</th>
<th>Actions</th> <th>Plugin</th>
</tr> <th>Actions</th>
</thead>
<tbody>
<template x-for="job in jobs" :key="job">
<tr :id="`chapter-${job.id}`">
<template x-if="job.plugin_id">
<td x-text="job.title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`<%= mangadex_base_url %>/chapter/${job.id}`" x-text="job.title"></td>
</template>
<template x-if="job.plugin_id">
<td x-text="job.manga_title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`<%= mangadex_base_url %>/manga/${job.manga_id}`" x-text="job.manga_title"></td>
</template>
<td x-text="`${job.success_count}/${job.pages}`"></td>
<td x-text="`${moment(job.time).fromNow()}`"></td>
<td>
<span :class="statusClass(job.status)" x-text="job.status"></span>
<template x-if="job.status_message.length > 0">
<div class="uk-inline">
<span uk-icon="info"></span>
<div uk-dropdown x-text="job.status_message" style="white-space: pre-line;"></div>
</div>
</template>
</td>
<td x-text="`${job.plugin_id || ''}`"></td>
<td>
<a @click="jobAction('delete', $event)" uk-icon="trash"></a>
<template x-if="job.status_message.length > 0">
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
</template>
</td>
</tr> </tr>
</template> </thead>
</tbody> <tbody>
</table> <template x-for="job in jobs" :key="job">
<tr :id="`chapter-${job.id}`">
<template x-if="job.plugin_id">
<td x-text="job.title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`<%= mangadex_base_url %>/chapter/${job.id}`" x-text="job.title"></td>
</template>
<template x-if="job.plugin_id">
<td x-text="job.manga_title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`<%= mangadex_base_url %>/manga/${job.manga_id}`" x-text="job.manga_title"></td>
</template>
<td x-text="`${job.success_count}/${job.pages}`"></td>
<td x-text="`${moment(job.time).fromNow()}`"></td>
<td>
<span :class="statusClass(job.status)" x-text="job.status"></span>
<template x-if="job.status_message.length > 0">
<div class="uk-inline">
<span uk-icon="info"></span>
<div uk-dropdown x-text="job.status_message" style="white-space: pre-line;"></div>
</div>
</template>
</td>
<td x-text="`${job.plugin_id || ''}`"></td>
<td>
<a @click="jobAction('delete', $event)" uk-icon="trash" uk-tooltip="Delete"></a>
<template x-if="job.status_message.length > 0">
<a @click="jobAction('retry', $event)" uk-icon="refresh" uk-tooltip="Retry"></a>
</template>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>
+156 -148
View File
@@ -1,162 +1,170 @@
<h2 class=uk-title>Download from MangaDex</h2> <h2 class=uk-title>Download from MangaDex</h2>
<div x-data="downloadComponent()" x-init="init()"> <div x-data="downloadComponent()" x-init="init()">
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;"> <div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
<div class="uk-width-expand"> <div class="uk-width-expand">
<input class="uk-input" type="text" :placeholder="searchAvailable ? 'Search MangaDex or enter a manga ID/URL' : 'MangaDex manga ID or URL'" x-model="searchInput" @keydown.enter.debounce="search()"> <input class="uk-input" type="text" :placeholder="searchAvailable ? 'Search MangaDex or enter a manga ID/URL' : 'MangaDex manga ID or URL'" x-model="searchInput" @keydown.enter.debounce="search()">
</div>
<div class="uk-width-auto">
<div uk-spinner class="uk-align-center" x-show="loading" x-cloak></div>
<button class="uk-button uk-button-default" x-show="!loading" @click="search()">Search</button>
</div>
</div>
<template x-if="mangaAry">
<div>
<p x-show="mangaAry.length === 0">No matching manga found.</p>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<template x-for="manga in mangaAry" :key="manga.id">
<div class="item" :data-id="manga.id" @click="chooseManga(manga)">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top uk-inline">
<img uk-img :data-src="manga.mainCover">
</div>
<div class="uk-card-body">
<h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="manga.title"></h3>
<p class="uk-text-meta" x-text="`ID: ${manga.id}`"></p>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<div x-show="data && data.chapters" x-cloak>
<div class"uk-grid-small" uk-grid>
<div class="uk-width-1-4@s">
<img :src="data.mainCover">
</div>
<div class="uk-width-1-4@s">
<p>Title: <a :href="`<%= mangadex_base_url %>/manga/${data.id}`" x-text="data.title"></a></p>
<p x-text="`Artist: ${data.artist}`"></p>
<p x-text="`Author: ${data.author}`"></p>
</div>
<div class="uk-form-stacked uk-width-1-2@s" id="filters">
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
<p class="uk-text-meta uk-margin-remove-top" x-text="`${chapters.length} chapters found`"></p>
<div class="uk-margin">
<label class="uk-form-label">Language</label>
<div class="uk-form-controls">
<select class="uk-select filter-field" x-model="langChoice" @change="filtersUpdated()">
<template x-for="lang in languages" :key="lang">
<option x-text="lang"></option>
</template>
</select>
</div>
</div> </div>
<div class="uk-width-auto">
<div class="uk-margin"> <div uk-spinner class="uk-align-center" x-show="loading" x-cloak></div>
<label class="uk-form-label">Group</label> <button class="uk-button uk-button-default" x-show="!loading" @click="search()">Search</button>
<div class="uk-form-controls">
<select class="uk-select filter-field" x-model="groupChoice" @change="filtersUpdated()">
<template x-for="group in groups" :key="group">
<option x-text="group"></option>
</template>
</select>
</div>
</div> </div>
<div class="uk-margin">
<label class="uk-form-label">Volume</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="volumeRange" @keydown.enter="filtersUpdated()">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Chapter</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="chapterRange" @keydown.enter="filtersUpdated()">
</div>
</div>
</div>
</div> </div>
<div class="uk-margin"> <template x-if="mangaAry">
<div class="uk-margin"> <div>
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button> <p x-show="mangaAry.length === 0">No matching manga found.</p>
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
<button class="uk-button uk-button-primary" @click="download()" x-show="!addingToDownload">Download Selected</button>
<div uk-spinner class="uk-margin-left" x-show="addingToDownload"></div>
</div>
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
</div>
<p x-text="`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters. Please use the filter options above to narrow down your search.`" x-show="chapters.length > chaptersLimit"></p>
<table class="uk-table uk-table-striped uk-overflow-auto" x-show="chapters.length <= chaptersLimit">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Language</th>
<th>Group</th>
<th>Volume</th>
<th>Chapter</th>
<th>Timestamp</th>
</tr>
</thead>
<template x-if="chapters.length <= chaptersLimit"> <div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<tbody id="selectable"> <template x-for="manga in mangaAry" :key="manga.id">
<template x-for="chp in chapters" :key="chp"> <div class="item" :data-id="manga.id" @click="chooseManga(manga)">
<tr class="ui-widget-content"> <div class="uk-card uk-card-default">
<td><a :href="`<%= mangadex_base_url %>/chapter/${chp.id}`" x-text="chp.id"></a></td> <div class="uk-card-media-top uk-inline">
<td x-text="chp.title"></td> <img uk-img :data-src="manga.mainCover">
<td x-text="chp.language"></td> </div>
<td> <div class="uk-card-body">
<template x-for="grp in Object.entries(chp.groups)"> <h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="manga.title"></h3>
<div> <p class="uk-text-meta" x-text="`ID: ${manga.id}`"></p>
<a :href="`<%= mangadex_base_url %>/group/${grp[1]}`" x-text="grp[0]"></a> </div>
</div> </div>
</div>
</template> </template>
</td> </div>
<td x-text="chp.volume"></td> </div>
<td x-text="chp.chapter"></td> </template>
<td x-text="`${moment.unix(chp.timestamp).fromNow()}`"></td>
</tr> <div x-show="data && data.chapters" x-cloak>
</template> <div class"uk-grid-small" uk-grid>
</tbody> <div class="uk-width-1-4@s">
</template> <img :src="data.mainCover">
</table> </div>
</div> <div class="uk-width-1-4@s">
<p>Title: <a :href="`<%= mangadex_base_url %>/manga/${data.id}`" x-text="data.title"></a></p>
<div id="modal" class="uk-flex-top" uk-modal="container: false"> <p x-text="`Artist: ${data.artist}`"></p>
<div class="uk-modal-dialog uk-margin-auto-vertical"> <p x-text="`Author: ${data.author}`"></p>
<button class="uk-modal-close-default" type="button" uk-close></button> </div>
<div class="uk-modal-header"> <div class="uk-form-stacked uk-width-1-2@s" id="filters">
<h3 class="uk-modal-title break-word" x-text="candidateManga.title"></h3> <p class="uk-text-lead uk-margin-remove-bottom">
</div> <span>Filter Chapters</span>
<div class="uk-modal-body"> <button class="uk-icon-button uk-align-right" uk-icon="rss" uk-tooltip="Subscribe" x-show="searchAvailable" @click="subscribe()"></button>
<div class="uk-grid"> </p>
<div class="uk-width-1-3@s"> <p class="uk-text-meta uk-margin-remove-top" x-text="`${chapters.length} chapters found`"></p>
<img uk-img data-width data-height :src="candidateManga.mainCover" style="width:100%;margin-bottom:10px;"> <div class="uk-margin">
<a :href="`<%= mangadex_base_url %>/manga/${candidateManga.id}`" x-text="`ID: ${candidateManga.id}`" class="uk-link-muted"></a> <label class="uk-form-label">Language</label>
</div> <div class="uk-form-controls">
<div class="uk-width-2-3@s" uk-overflow-auto> <select class="uk-select filter-field" x-model="langChoice" @change="filtersUpdated()">
<p x-text="candidateManga.description"></p> <template x-for="lang in languages" :key="lang">
</div> <option x-text="lang"></option>
</template>
</select>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Group</label>
<div class="uk-form-controls">
<select class="uk-select filter-field" x-model="groupChoice" @change="filtersUpdated()">
<template x-for="group in groups" :key="group">
<option x-text="group"></option>
</template>
</select>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Volume</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="volumeRange" @keydown.enter="filtersUpdated()">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Chapter</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="chapterRange" @keydown.enter="filtersUpdated()">
</div>
</div>
</div>
</div>
<div class="uk-margin">
<div class="uk-margin">
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
<button class="uk-button uk-button-primary" @click="download()" x-show="!addingToDownload">Download Selected</button>
<div uk-spinner class="uk-margin-left" x-show="addingToDownload"></div>
</div>
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
</div>
<p x-text="`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters. Please use the filter options above to narrow down your search.`" x-show="chapters.length > chaptersLimit"></p>
<div class="uk-overflow-auto">
<table class="uk-table uk-table-striped" x-show="chapters.length <= chaptersLimit">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Language</th>
<th>Group</th>
<th>Volume</th>
<th>Chapter</th>
<th>Timestamp</th>
</tr>
</thead>
<template x-if="chapters.length <= chaptersLimit">
<tbody id="selectable">
<template x-for="chp in chapters" :key="chp">
<tr class="ui-widget-content">
<td><a :href="`<%= mangadex_base_url %>/chapter/${chp.id}`" x-text="chp.id"></a></td>
<td x-text="chp.title"></td>
<td x-text="chp.language"></td>
<td>
<template x-for="grp in Object.entries(chp.groups)">
<div>
<a :href="`<%= mangadex_base_url %>/group/${grp[1]}`" x-text="grp[0]"></a>
</div>
</template>
</td>
<td x-text="chp.volume"></td>
<td x-text="chp.chapter"></td>
<td x-text="`${moment.unix(chp.timestamp).fromNow()}`"></td>
</tr>
</template>
</tbody>
</template>
</table>
</div>
</div>
<div id="modal" class="uk-flex-top" uk-modal="container: false">
<div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<h3 class="uk-modal-title break-word" x-text="candidateManga.title"></h3>
</div>
<div class="uk-modal-body">
<div class="uk-grid">
<div class="uk-width-1-3@s">
<img uk-img data-width data-height :src="candidateManga.mainCover" style="width:100%;margin-bottom:10px;">
<a :href="`<%= mangadex_base_url %>/manga/${candidateManga.id}`" x-text="`ID: ${candidateManga.id}`" class="uk-link-muted"></a>
</div>
<div class="uk-width-2-3@s">
<p x-text="candidateManga.description"></p>
</div>
</div>
</div>
<div class="uk-modal-footer">
<button class="uk-button uk-button-primary" type="button" @click="confirmManga(candidateManga.id)">Choose</button>
</div>
</div> </div>
</div>
<div class="uk-modal-footer">
<button class="uk-button uk-button-primary" type="button" @click="confirmManga(candidateManga.id)">Choose</button>
</div>
</div> </div>
</div>
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>
<%= render_component "moment" %> <%= render_component "moment" %>
<%= render_component "jquery-ui" %> <%= render_component "jquery-ui" %>
<script src="<%= base_url %>js/alert.js"></script> <script>
<script src="<%= base_url %>js/download.js"></script> const mangadex_base_url = "<%= mangadex_base_url %>";
</script>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/download.js"></script>
<% end %> <% end %>
+81 -79
View File
@@ -1,89 +1,91 @@
<!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">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> <li><a href="<%= base_url %>download/subscription">Subscription Manager</a></li>
</li> </ul>
<% end %> </li>
<hr uk-divider> <% end %>
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li> <hr uk-divider>
<li><a href="<%= base_url %>logout">Logout</a></li> <li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
</ul> <li><a href="<%= base_url %>logout">Logout</a></li>
</div> </ul>
</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">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>
<li><a href="<%= base_url %>download/subscription">Subscription 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>
+29 -27
View File
@@ -3,34 +3,36 @@
<div x-show="!empty"> <div x-show="!empty">
<p>The following items were present in your library, but now we can't find them anymore. If you deleted them mistakenly, try to recover the files or folders, put them back to where they were, and rescan the library. Otherwise, you can safely delete them and the associated metadata using the buttons below to free up database space.</p> <p>The following items were present in your library, but now we can't find them anymore. If you deleted them mistakenly, try to recover the files or folders, put them back to where they were, and rescan the library. Otherwise, you can safely delete them and the associated metadata using the buttons below to free up database space.</p>
<button class="uk-button uk-button-danger" @click="rmAll()">Delete All</button> <button class="uk-button uk-button-danger" @click="rmAll()">Delete All</button>
<table class="uk-table uk-table-striped uk-overflow-auto"> <div class="uk-overflow-auto">
<thead> <table class="uk-table uk-table-striped">
<tr> <thead>
<th>Type</th> <tr>
<th>Relative Path</th> <th>Type</th>
<th>ID</th> <th>Relative Path</th>
<th>Actions</th> <th>ID</th>
</tr> <th>Actions</th>
</thead>
<tbody>
<template x-for="title in titles" :key="title">
<tr :id="`title-${title.id}`">
<td>Title</td>
<td x-text="title.path"></td>
<td x-text="title.id"></td>
<td><a @click="rm($event)" uk-icon="trash"></a></td>
</tr> </tr>
</template> </thead>
<template x-for="entry in entries" :key="entry"> <tbody>
<tr :id="`entry-${entry.id}`"> <template x-for="title in titles" :key="title">
<td>Entry</td> <tr :id="`title-${title.id}`">
<td x-text="entry.path"></td> <td>Title</td>
<td x-text="entry.id"></td> <td x-text="title.path"></td>
<td><a @click="rm($event)" uk-icon="trash"></a></td> <td x-text="title.id"></td>
</tr> <td><a @click="rm($event)" uk-icon="trash"></a></td>
</template> </tr>
</tbody> </template>
</table> <template x-for="entry in entries" :key="entry">
<tr :id="`entry-${entry.id}`">
<td>Entry</td>
<td x-text="entry.path"></td>
<td x-text="entry.id"></td>
<td><a @click="rm($event)" uk-icon="trash"></a></td>
</tr>
</template>
</tbody>
</table>
</div>
</div> </div>
</div> </div>
+4 -2
View File
@@ -56,8 +56,10 @@
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div> <div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
</div> </div>
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p> <p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
<table class="uk-table uk-table-striped uk-overflow-auto tablesorter"> <div class="uk-overflow-auto">
</table> <table class="uk-table uk-table-striped tablesorter">
</table>
</div>
</div> </div>
<% end %> <% end %>
+12 -4
View File
@@ -25,11 +25,11 @@
<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>
@@ -80,6 +80,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 +91,13 @@
</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>
<hr class="uk-divider-icon"> <hr class="uk-divider-icon">
<div class="uk-margin"> <div class="uk-margin">
@@ -110,12 +118,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
View File
@@ -0,0 +1,54 @@
<h2 class="uk-title">MangaDex Subscription Manager</h2>
<div x-data="component()" x-init="init()">
<p x-show="available === false">The subscription manager uses a MangaDex API that requires authentication. Please <a href="<%= base_url %>admin/mangadex">connect to MangaDex</a> before using this feature.</p>
<p x-show="available && subscriptions.length === 0">No subscription found. Go to the <a href="<%= base_url %>download">MangaDex download page</a> and start subscribing.</p>
<template x-if="subscriptions.length > 0">
<div class="uk-overflow-auto">
<table class="uk-table uk-table-striped">
<thead>
<tr>
<th>Manga ID</th>
<th>Language</th>
<th>Group ID</th>
<th>Volume Range</th>
<th>Chapter Range</th>
<th>Creator</th>
<th>Last Checked</th>
<th>Created At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template x-for="sub in subscriptions" :key="sub">
<tr>
<td><a :href="`<%= mangadex_base_url %>/manga/${sub.manga_id}`" x-text="sub.manga_id"></a></td>
<td x-text="sub.language || 'All'"></td>
<td>
<a x-show="sub.group_id" :href="`<%= mangadex_base_url %>/group/${sub.group_id}`" x-text="sub.group_id"></a>
<span x-show="!sub.group_id">All</span>
</td>
<td x-text="formatRange(sub.min_volume, sub.max_volume)"></td>
<td x-text="formatRange(sub.min_chapter, sub.max_chapter)"></td>
<td x-text="sub.username"></td>
<td x-text="`${moment.unix(sub.last_checked).fromNow()}`"></td>
<td x-text="`${moment.unix(sub.created_at).fromNow()}`"></td>
<td :data-id="sub.id">
<a @click="check($event)" x-show="sub.username === '<%= username %>'" uk-icon="refresh" uk-tooltip="Check for updates"></a>
<a @click="rm($event)" x-show="sub.username === '<%= username %>'" uk-icon="trash" uk-tooltip="Delete"></a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
<% content_for "script" do %>
<%= render_component "moment" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/subscription.js"></script>
<% end %>