diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a7e0a47..f15ce4d 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
container:
- image: crystallang/crystal:0.34.0-alpine
+ image: crystallang/crystal:0.35.1-alpine
steps:
- uses: actions/checkout@v2
diff --git a/Dockerfile b/Dockerfile
index ee1f6d1..7dc5f01 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM crystallang/crystal:0.34.0-alpine AS builder
+FROM crystallang/crystal:0.35.1-alpine AS builder
WORKDIR /Mango
diff --git a/Dockerfile.arm32v7 b/Dockerfile.arm32v7
index 7f52e48..854f4ec 100644
--- a/Dockerfile.arm32v7
+++ b/Dockerfile.arm32v7
@@ -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 git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.34.0 && make deps && cd ..
+RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/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 ..
diff --git a/Dockerfile.arm64v8 b/Dockerfile.arm64v8
index 67675c6..888f797 100644
--- a/Dockerfile.arm64v8
+++ b/Dockerfile.arm64v8
@@ -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 git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.34.0 && make deps && cd ..
+RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/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 ..
diff --git a/README.md b/README.md
index d624a1d..c1c7d65 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI
```
- Mango - Manga Server and Web Reader. Version 0.16.0
+ Mango - Manga Server and Web Reader. Version 0.17.0
Usage:
diff --git a/dev/linewidth.sh b/dev/linewidth.sh
index 7f76fcb..6e83fbc 100755
--- a/dev/linewidth.sh
+++ b/dev/linewidth.sh
@@ -1,5 +1,5 @@
#!/bin/sh
-[ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | tee /dev/tty)" ] \
+[ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | grep -v "routes/api.cr" | tee /dev/tty)" ] \
&& echo "The above lines exceed the 80 characters limit" \
|| exit 0
diff --git a/public/js/common.js b/public/js/common.js
new file mode 100644
index 0000000..a80fc53
--- /dev/null
+++ b/public/js/common.js
@@ -0,0 +1,147 @@
+/**
+ * --- Alpine helper functions
+ */
+
+/**
+ * Set an alpine.js property
+ *
+ * @function setProp
+ * @param {string} key - Key of the data property
+ * @param {*} prop - The data property
+ * @param {string} selector - The jQuery selector to the root element
+ */
+const setProp = (key, prop, selector = '#root') => {
+ $(selector).get(0).__x.$data[key] = prop;
+};
+
+/**
+ * Get an alpine.js property
+ *
+ * @function getProp
+ * @param {string} key - Key of the data property
+ * @param {string} selector - The jQuery selector to the root element
+ * @return {*} The data property
+ */
+const getProp = (key, selector = '#root') => {
+ return $(selector).get(0).__x.$data[key];
+};
+
+/**
+ * --- Theme related functions
+ * Note: In the comments below we treat "theme" and "theme setting"
+ * differently. A theme can have only two values, either "dark" or
+ * "light", while a theme setting can have the third value "system".
+ */
+
+/**
+ * Check if the system setting prefers dark theme.
+ * from https://flaviocopes.com/javascript-detect-dark-mode/
+ *
+ * @function preferDarkMode
+ * @return {bool}
+ */
+const preferDarkMode = () => {
+ return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
+};
+
+/**
+ * Check whether a given string represents a valid theme setting
+ *
+ * @function validThemeSetting
+ * @param {string} theme - The string representing the theme setting
+ * @return {bool}
+ */
+const validThemeSetting = (theme) => {
+ return ['dark', 'light', 'system'].indexOf(theme) >= 0;
+};
+
+/**
+ * Load theme setting from local storage, or use 'light'
+ *
+ * @function loadThemeSetting
+ * @return {string} A theme setting ('dark', 'light', or 'system')
+ */
+const loadThemeSetting = () => {
+ let str = localStorage.getItem('theme');
+ if (!str || !validThemeSetting(str)) str = 'light';
+ return str;
+};
+
+/**
+ * Load the current theme (not theme setting)
+ *
+ * @function loadTheme
+ * @return {string} The current theme to use ('dark' or 'light')
+ */
+const loadTheme = () => {
+ let setting = loadThemeSetting();
+ if (setting === 'system') {
+ setting = preferDarkMode() ? 'dark' : 'light';
+ }
+ return setting;
+};
+
+/**
+ * Save a theme setting
+ *
+ * @function saveThemeSetting
+ * @param {string} setting - A theme setting
+ */
+const saveThemeSetting = setting => {
+ if (!validThemeSetting(setting)) setting = 'light';
+ localStorage.setItem('theme', setting);
+};
+
+/**
+ * Toggle the current theme. When the current theme setting is 'system', it
+ * will be changed to either 'light' or 'dark'
+ *
+ * @function toggleTheme
+ */
+const toggleTheme = () => {
+ const theme = loadTheme();
+ const newTheme = theme === 'dark' ? 'light' : 'dark';
+ saveThemeSetting(newTheme);
+ setTheme(newTheme);
+};
+
+/**
+ * Apply a theme, or load a theme and then apply it
+ *
+ * @function setTheme
+ * @param {string?} theme - (Optional) The theme to apply. When omitted, use
+ * `loadTheme` to get a theme and apply it.
+ */
+const setTheme = (theme) => {
+ if (!theme) theme = loadTheme();
+ if (theme === 'dark') {
+ $('html').css('background', 'rgb(20, 20, 20)');
+ $('body').addClass('uk-light');
+ $('.uk-card').addClass('uk-card-secondary');
+ $('.uk-card').removeClass('uk-card-default');
+ $('.ui-widget-content').addClass('dark');
+ } else {
+ $('html').css('background', '');
+ $('body').removeClass('uk-light');
+ $('.uk-card').removeClass('uk-card-secondary');
+ $('.uk-card').addClass('uk-card-default');
+ $('.ui-widget-content').removeClass('dark');
+ }
+};
+
+// do it before document is ready to prevent the initial flash of white on
+// most pages
+setTheme();
+$(() => {
+ // hack for the reader page
+ setTheme();
+
+ // on system dark mode setting change
+ if (window.matchMedia) {
+ window.matchMedia('(prefers-color-scheme: dark)')
+ .addEventListener('change', event => {
+ if (loadThemeSetting() === 'system')
+ setTheme(event.matches ? 'dark' : 'light');
+ });
+ }
+});
diff --git a/public/js/download-manager.js b/public/js/download-manager.js
index 4ff6e2e..6136232 100644
--- a/public/js/download-manager.js
+++ b/public/js/download-manager.js
@@ -1,28 +1,42 @@
-$(() => {
- $('input.uk-checkbox').each((i, e) => {
- $(e).change(() => {
- loadConfig();
+/**
+ * Get the current queue and update the view
+ *
+ * @function load
+ */
+const load = () => {
+ try {
+ setProp('loading', true);
+ } catch {}
+ $.ajax({
+ type: 'GET',
+ url: base_url + 'api/admin/mangadex/queue',
+ dataType: 'json'
+ })
+ .done(data => {
+ if (!data.success && data.error) {
+ alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
+ return;
+ }
+ setProp('jobs', data.jobs);
+ setProp('paused', data.paused);
+ })
+ .fail((jqXHR, status) => {
+ alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
+ })
+ .always(() => {
+ setProp('loading', false);
});
- });
- loadConfig();
- load();
-
- const intervalMS = 5000;
- setTimeout(() => {
- setInterval(() => {
- if (globalConfig.autoRefresh !== true) return;
- load();
- }, intervalMS);
- }, intervalMS);
-});
-var globalConfig = {};
-var loading = false;
-
-const loadConfig = () => {
- globalConfig.autoRefresh = $('#auto-refresh').prop('checked');
};
-const remove = (id) => {
- var url = base_url + 'api/admin/mangadex/queue/delete';
+
+/**
+ * Perform an action on either a specific job or the entire queue
+ *
+ * @function jobAction
+ * @param {string} action - The action to perform. Should be either 'delete' or 'retry'
+ * @param {string?} id - (Optional) A job ID. When omitted, apply the action to the queue
+ */
+const jobAction = (action, id) => {
+ let url = `${base_url}api/admin/mangadex/queue/${action}`;
if (id !== undefined)
url += '?' + $.param({
id: id
@@ -35,42 +49,24 @@ const remove = (id) => {
})
.done(data => {
if (!data.success && data.error) {
- alert('danger', `Failed to remove job from download queue. Error: ${data.error}`);
+ alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
return;
}
load();
})
.fail((jqXHR, status) => {
- alert('danger', `Failed to remove job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
- });
-};
-const refresh = (id) => {
- var url = base_url + 'api/admin/mangadex/queue/retry';
- if (id !== undefined)
- url += '?' + $.param({
- id: id
- });
- console.log(url);
- $.ajax({
- type: 'POST',
- url: url,
- dataType: 'json'
- })
- .done(data => {
- if (!data.success && data.error) {
- alert('danger', `Failed to restart download job. Error: ${data.error}`);
- return;
- }
- load();
- })
- .fail((jqXHR, status) => {
- alert('danger', `Failed to restart download job. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
+ alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
+
+/**
+ * Pause/resume the download
+ *
+ * @function toggle
+ */
const toggle = () => {
- $('#pause-resume-btn').attr('disabled', '');
- const paused = $('#pause-resume-btn').text() === 'Resume download';
- const action = paused ? 'resume' : 'pause';
+ setProp('toggling', true);
+ const action = getProp('paused') ? 'resume' : 'pause';
const url = `${base_url}api/admin/mangadex/queue/${action}`;
$.ajax({
type: 'POST',
@@ -82,64 +78,47 @@ const toggle = () => {
})
.always(() => {
load();
- $('#pause-resume-btn').removeAttr('disabled');
+ setProp('toggling', false);
});
};
-const load = () => {
- if (loading) return;
- loading = true;
- console.log('fetching');
- $.ajax({
- type: 'GET',
- url: base_url + 'api/admin/mangadex/queue',
- dataType: 'json'
- })
- .done(data => {
- if (!data.success && data.error) {
- alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
- return;
- }
- console.log(data);
- const btnText = data.paused ? "Resume download" : "Pause download";
- $('#pause-resume-btn').text(btnText);
- $('#pause-resume-btn').removeAttr('hidden');
- const rows = data.jobs.map(obj => {
- var cls = 'label ';
- if (obj.status === 'Pending')
- cls += 'label-pending';
- if (obj.status === 'Completed')
- cls += 'label-success';
- if (obj.status === 'Error')
- cls += 'label-danger';
- if (obj.status === 'MissingPages')
- cls += 'label-warning';
- const info = obj.status_message.length > 0 ? '' : '';
- const statusSpan = `${obj.status} ${info}`;
- const dropdown = obj.status_message.length > 0 ? `
${obj.status_message}
` : '';
- const retryBtn = obj.status_message.length > 0 ? `` : '';
- return `
- ${obj.plugin_id ? obj.title : `${obj.title}`} |
- ${obj.plugin_id ? obj.manga_title : `${obj.manga_title}`} |
- ${obj.success_count}/${obj.pages} |
- ${moment(obj.time).fromNow()} |
- ${statusSpan} ${dropdown} |
- ${obj.plugin_id || ""} |
-
-
- ${retryBtn}
- |
-
`;
- });
-
- const tbody = `${rows.join('')}`;
- $('tbody').remove();
- $('table').append(tbody);
- })
- .fail((jqXHR, status) => {
- alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
- })
- .always(() => {
- loading = false;
- });
+/**
+ * Get the uk-label class name for a given job status
+ *
+ * @function statusClass
+ * @param {string} status - The job status
+ * @return {string} The class name string
+ */
+const statusClass = status => {
+ let cls = 'label ';
+ switch (status) {
+ case 'Pending':
+ cls += 'label-pending';
+ break;
+ case 'Completed':
+ cls += 'label-success';
+ break;
+ case 'Error':
+ cls += 'label-danger';
+ break;
+ case 'MissingPages':
+ cls += 'label-warning';
+ break;
+ }
+ return cls;
};
+
+$(() => {
+ const ws = new WebSocket(`ws://${location.host}/api/admin/mangadex/queue`);
+ ws.onmessage = event => {
+ const data = JSON.parse(event.data);
+ setProp('jobs', data.jobs);
+ setProp('paused', data.paused);
+ };
+ ws.onerror = err => {
+ alert('danger', `Socket connection failed. Error: ${err}`);
+ };
+ ws.onclose = err => {
+ alert('danger', 'Socket connection failed');
+ };
+});
diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js
index 746dda8..e1fba96 100644
--- a/public/js/plugin-download.js
+++ b/public/js/plugin-download.js
@@ -33,14 +33,13 @@ const search = () => {
if (searching)
return;
- const query = $('#search-input').val();
+ const query = $.param({
+ query: $('#search-input').val(),
+ plugin: pid
+ });
$.ajax({
- type: 'POST',
- url: base_url + 'api/admin/plugin/list',
- data: JSON.stringify({
- query: query,
- plugin: pid
- }),
+ type: 'GET',
+ url: `${base_url}api/admin/plugin/list?${query}`,
contentType: "application/json",
dataType: 'json'
})
diff --git a/public/js/reader.js b/public/js/reader.js
index 6d44a06..7ff46aa 100644
--- a/public/js/reader.js
+++ b/public/js/reader.js
@@ -61,28 +61,6 @@ const updateMode = (mode, targetPage) => {
});
};
-/**
- * Set an alpine.js property
- *
- * @function setProp
- * @param {string} key - Key of the data property
- * @param {*} prop - The data property
- */
-const setProp = (key, prop) => {
- $('#root').get(0).__x.$data[key] = prop;
-};
-
-/**
- * Get an alpine.js property
- *
- * @function getProp
- * @param {string} key - Key of the data property
- * @return {*} The data property
- */
-const getProp = (key) => {
- return $('#root').get(0).__x.$data[key];
-};
-
/**
* Get dimension of the pages in the entry from the API and update the view
*/
@@ -163,10 +141,9 @@ const waitForPage = (idx, cb) => {
* Show the control modal
*
* @function showControl
- * @param {object} event - The onclick event that triggers the function
+ * @param {string} idx - One-based index of the current page
*/
-const showControl = (event) => {
- const idx = parseInt($(event.currentTarget).attr('id'));
+const showControl = (idx) => {
const pageCount = $('#page-select > option').length;
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
$('#progress-label').text(progressText);
@@ -213,6 +190,8 @@ const setupScroller = () => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
+
+ setProp('curItem', getProp('items')[current - 1]);
replaceHistory(current);
}
});
@@ -240,15 +219,19 @@ const saveProgress = (idx, cb) => {
lastSavedPage = idx;
console.log('saving progress', idx);
- const url = `${base_url}api/progress/${tid}/${idx}?${$.param({entry: eid})}`;
- $.post(url)
- .then(data => {
- if (data.error) throw new Error(data.error);
+ const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
+ $.ajax({
+ method: 'PUT',
+ url: url,
+ dataType: 'json'
+ })
+ .done(data => {
+ if (data.error)
+ alert('danger', data.error);
if (cb) cb();
})
- .catch(e => {
- console.error(e);
- alert('danger', e);
+ .fail((jqXHR, status) => {
+ alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}
};
diff --git a/public/js/theme.js b/public/js/theme.js
deleted file mode 100644
index 3cd6690..0000000
--- a/public/js/theme.js
+++ /dev/null
@@ -1,72 +0,0 @@
-// https://flaviocopes.com/javascript-detect-dark-mode/
-const preferDarkMode = () => {
- return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
-};
-
-const validThemeSetting = (theme) => {
- return ['dark', 'light', 'system'].indexOf(theme) >= 0;
-};
-
-// dark / light / system
-const loadThemeSetting = () => {
- let str = localStorage.getItem('theme');
- if (!str || !validThemeSetting(str)) str = 'light';
- return str;
-};
-
-// dark / light
-const loadTheme = () => {
- let setting = loadThemeSetting();
- if (setting === 'system') {
- setting = preferDarkMode() ? 'dark' : 'light';
- }
- return setting;
-};
-
-const saveThemeSetting = setting => {
- if (!validThemeSetting(setting)) setting = 'light';
- localStorage.setItem('theme', setting);
-};
-
-// when toggled, Auto will be changed to light or dark
-const toggleTheme = () => {
- const theme = loadTheme();
- const newTheme = theme === 'dark' ? 'light' : 'dark';
- saveThemeSetting(newTheme);
- setTheme(newTheme);
-};
-
-const setTheme = (theme) => {
- if (!theme) theme = loadTheme();
- if (theme === 'dark') {
- $('html').css('background', 'rgb(20, 20, 20)');
- $('body').addClass('uk-light');
- $('.uk-card').addClass('uk-card-secondary');
- $('.uk-card').removeClass('uk-card-default');
- $('.ui-widget-content').addClass('dark');
- } else {
- $('html').css('background', '');
- $('body').removeClass('uk-light');
- $('.uk-card').removeClass('uk-card-secondary');
- $('.uk-card').addClass('uk-card-default');
- $('.ui-widget-content').removeClass('dark');
- }
-};
-
-// do it before document is ready to prevent the initial flash of white on
-// most pages
-setTheme();
-
-$(() => {
- // hack for the reader page
- setTheme();
-
- // on system dark mode setting change
- if (window.matchMedia) {
- window.matchMedia('(prefers-color-scheme: dark)')
- .addEventListener('change', event => {
- if (loadThemeSetting() === 'system')
- setTheme(event.matches ? 'dark' : 'light');
- });
- }
-});
diff --git a/public/js/title.js b/public/js/title.js
index 0942cd1..d46c173 100644
--- a/public/js/title.js
+++ b/public/js/title.js
@@ -55,7 +55,7 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
- $('#modal-download-btn').attr('href', `${base_url}opds/download/${titleID}/${entryID}`);
+ $('#modal-download-btn').attr('href', `${base_url}api/download/${titleID}/${entryID}`);
UIkit.modal($('#modal')).show();
}
@@ -63,18 +63,27 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
const updateProgress = (tid, eid, page) => {
let url = `${base_url}api/progress/${tid}/${page}`
const query = $.param({
- entry: eid
+ eid: eid
});
if (eid)
url += `?${query}`;
- $.post(url, (data) => {
- if (data.success) {
- location.reload();
- } else {
- error = data.error;
- alert('danger', error);
- }
- });
+
+ $.ajax({
+ method: 'PUT',
+ url: url,
+ dataType: 'json'
+ })
+ .done(data => {
+ if (data.success) {
+ location.reload();
+ } else {
+ error = data.error;
+ alert('danger', error);
+ }
+ })
+ .fail((jqXHR, status) => {
+ alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
+ });
};
const renameSubmit = (name, eid) => {
@@ -89,14 +98,14 @@ const renameSubmit = (name, eid) => {
}
const query = $.param({
- entry: eid
+ eid: eid
});
let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
if (eid)
url += `?${query}`;
$.ajax({
- type: 'POST',
+ type: 'PUT',
url: url,
contentType: "application/json",
dataType: 'json'
@@ -131,6 +140,7 @@ const edit = (eid) => {
const displayNameField = $('#display-name-field');
displayNameField.attr('value', displayName);
+ console.log(displayNameField);
displayNameField.keyup(event => {
if (event.keyCode === 13) {
renameSubmit(displayNameField.val(), eid);
@@ -150,10 +160,10 @@ const setupUpload = (eid) => {
const bar = $('#upload-progress').get(0);
const titleId = upload.attr('data-title-id');
const queryObj = {
- title: titleId
+ tid: titleId
};
if (eid)
- queryObj['entry'] = eid;
+ queryObj['eid'] = eid;
const query = $.param(queryObj);
const url = `${base_url}api/admin/upload/cover?${query}`;
console.log(url);
@@ -218,9 +228,9 @@ const selectedIDs = () => {
const bulkProgress = (action, el) => {
const tid = $(el).attr('data-id');
const ids = selectedIDs();
- const url = `${base_url}api/bulk-progress/${action}/${tid}`;
+ const url = `${base_url}api/bulk_progress/${action}/${tid}`;
$.ajax({
- type: 'POST',
+ type: 'PUT',
url: url,
contentType: "application/json",
dataType: 'json',
diff --git a/public/js/user.js b/public/js/user.js
index 9d69fd4..b1b910f 100644
--- a/public/js/user.js
+++ b/public/js/user.js
@@ -1,11 +1,16 @@
-function remove(username) {
- $.post(base_url + 'api/admin/user/delete/' + username, function(data) {
- if (data.success) {
- location.reload();
- }
- else {
- error = data.error;
- alert('danger', error);
- }
- });
-}
+const remove = (username) => {
+ $.ajax({
+ url: `${base_url}api/admin/user/delete/${username}`,
+ type: 'DELETE',
+ dataType: 'json'
+ })
+ .done(data => {
+ if (data.success)
+ location.reload();
+ else
+ alert('danger', data.error);
+ })
+ .fail((jqXHR, status) => {
+ alert('danger', `Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
+ });
+};
diff --git a/shard.lock b/shard.lock
index f2604c9..6189fcd 100644
--- a/shard.lock
+++ b/shard.lock
@@ -1,62 +1,70 @@
-version: 1.0
+version: 2.0
shards:
ameba:
- github: crystal-ameba/ameba
+ git: https://github.com/crystal-ameba/ameba.git
version: 0.12.1
archive:
- github: hkalexling/archive.cr
+ git: https://github.com/hkalexling/archive.cr.git
version: 0.4.0
baked_file_system:
- github: schovi/baked_file_system
- version: 0.9.8
+ git: https://github.com/schovi/baked_file_system.git
+ version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
clim:
- github: at-grandpa/clim
+ git: https://github.com/at-grandpa/clim.git
version: 0.12.0
db:
- github: crystal-lang/crystal-db
+ git: https://github.com/crystal-lang/crystal-db.git
version: 0.9.0
duktape:
- github: jessedoyle/duktape.cr
+ git: https://github.com/jessedoyle/duktape.cr.git
version: 0.20.0
exception_page:
- github: crystal-loot/exception_page
+ git: https://github.com/crystal-loot/exception_page.git
version: 0.1.4
http_proxy:
- github: mamantoha/http_proxy
+ git: https://github.com/mamantoha/http_proxy.git
version: 0.7.1
image_size:
- github: hkalexling/image_size.cr
+ git: https://github.com/hkalexling/image_size.cr.git
version: 0.4.0
kemal:
- github: kemalcr/kemal
- version: 0.26.1
+ git: https://github.com/kemalcr/kemal.git
+ version: 0.27.0
kemal-session:
- github: kemalcr/kemal-session
+ git: https://github.com/kemalcr/kemal-session.git
version: 0.12.1
kilt:
- github: jeromegn/kilt
+ git: https://github.com/jeromegn/kilt.git
version: 0.4.0
+ koa:
+ git: https://github.com/hkalexling/koa.git
+ version: 0.5.0
+
myhtml:
- github: kostya/myhtml
+ git: https://github.com/kostya/myhtml.git
version: 1.5.1
+ open_api:
+ git: https://github.com/jreinert/open_api.cr.git
+ version: 1.2.1+git.commit.95e4df2ca10b1fe88b8b35c62a18b06a10267b6c
+
radix:
- github: luislavena/radix
+ git: https://github.com/luislavena/radix.git
version: 0.3.9
sqlite3:
- github: crystal-lang/crystal-sqlite3
+ git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.16.0
diff --git a/shard.yml b/shard.yml
index bca96d9..3fe7aca 100644
--- a/shard.yml
+++ b/shard.yml
@@ -1,5 +1,5 @@
name: mango
-version: 0.16.0
+version: 0.17.0
authors:
- Alex Ling
@@ -8,7 +8,7 @@ targets:
mango:
main: src/mango.cr
-crystal: 0.34.0
+crystal: 0.35.1
license: MIT
@@ -21,6 +21,7 @@ dependencies:
github: crystal-lang/crystal-sqlite3
baked_file_system:
github: schovi/baked_file_system
+ version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
archive:
github: hkalexling/archive.cr
ameba:
@@ -36,3 +37,5 @@ dependencies:
github: mamantoha/http_proxy
image_size:
github: hkalexling/image_size.cr
+ koa:
+ github: hkalexling/koa
diff --git a/src/archive.cr b/src/archive.cr
index 29dedfb..068b6d5 100644
--- a/src/archive.cr
+++ b/src/archive.cr
@@ -1,13 +1,13 @@
-require "zip"
+require "compress/zip"
require "archive"
-# A unified class to handle all supported archive formats. It uses the ::Zip
-# module in crystal standard library if the target file is a zip archive.
-# Otherwise it uses `archive.cr`.
+# A unified class to handle all supported archive formats. It uses the
+# Compress::Zip module in crystal standard library if the target file is
+# a zip archive. Otherwise it uses `archive.cr`.
class ArchiveFile
def initialize(@filename : String)
if [".cbz", ".zip"].includes? File.extname filename
- @archive_file = Zip::File.new filename
+ @archive_file = Compress::Zip::File.new filename
else
@archive_file = Archive::File.new filename
end
@@ -20,16 +20,16 @@ class ArchiveFile
end
def close
- if @archive_file.is_a? Zip::File
- @archive_file.as(Zip::File).close
+ if @archive_file.is_a? Compress::Zip::File
+ @archive_file.as(Compress::Zip::File).close
end
end
# Lists all file entries
def entries
- ary = [] of Zip::File::Entry | Archive::Entry
+ ary = [] of Compress::Zip::File::Entry | Archive::Entry
@archive_file.entries.map do |e|
- if (e.is_a? Zip::File::Entry && e.file?) ||
+ if (e.is_a? Compress::Zip::File::Entry && e.file?) ||
(e.is_a? Archive::Entry && e.info.file?)
ary.push e
end
@@ -37,8 +37,8 @@ class ArchiveFile
ary
end
- def read_entry(e : Zip::File::Entry | Archive::Entry) : Bytes?
- if e.is_a? Zip::File::Entry
+ def read_entry(e : Compress::Zip::File::Entry | Archive::Entry) : Bytes?
+ if e.is_a? Compress::Zip::File::Entry
data = nil
e.open do |io|
slice = Bytes.new e.uncompressed_size
diff --git a/src/handlers/static_handler.cr b/src/handlers/static_handler.cr
index b3a2d0e..edb772d 100644
--- a/src/handlers/static_handler.cr
+++ b/src/handlers/static_handler.cr
@@ -23,7 +23,7 @@ class StaticHandler < Kemal::Handler
slice = Bytes.new file.size
file.read slice
- return send_file env, slice, file.mime_type
+ return send_file env, slice, MIME.from_filename file.path
end
call_next env
end
diff --git a/src/library/entry.cr b/src/library/entry.cr
index 7a8cfae..810c6c8 100644
--- a/src/library/entry.cr
+++ b/src/library/entry.cr
@@ -47,8 +47,7 @@ class Entry
def to_json(json : JSON::Builder)
json.object do
- {% for str in ["zip_path", "title", "size", "id",
- "encoded_path", "encoded_title"] %}
+ {% for str in ["zip_path", "title", "size", "id"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "title_id", @book.id
diff --git a/src/library/title.cr b/src/library/title.cr
index 6754504..f3d8b2f 100644
--- a/src/library/title.cr
+++ b/src/library/title.cr
@@ -56,7 +56,7 @@ class Title
def to_json(json : JSON::Builder)
json.object do
- {% for str in ["dir", "title", "id", "encoded_title"] %}
+ {% for str in ["dir", "title", "id"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "display_name", display_name
diff --git a/src/logger.cr b/src/logger.cr
index 3777e9c..92be20e 100644
--- a/src/logger.cr
+++ b/src/logger.cr
@@ -26,9 +26,9 @@ class Logger
{% end %}
@log = Log.for("")
-
@backend = Log::IOBackend.new
- @backend.formatter = ->(entry : Log::Entry, io : IO) do
+
+ format_proc = ->(entry : Log::Entry, io : IO) do
color = :default
{% begin %}
case entry.severity.label.to_s().downcase
@@ -45,12 +45,14 @@ class Logger
io << entry.message
end
- Log.builder.bind "*", @@severity, @backend
+ @backend.formatter = Log::Formatter.new &format_proc
+ Log.setup @@severity, @backend
end
# Ignores @@severity and always log msg
def log(msg)
- @backend.write Log::Entry.new "", Log::Severity::None, msg, nil
+ @backend.write Log::Entry.new "", Log::Severity::None, msg,
+ Log::Metadata.empty, nil
end
def self.log(msg)
diff --git a/src/mangadex/downloader.cr b/src/mangadex/downloader.cr
index 793763d..e2babb6 100644
--- a/src/mangadex/downloader.cr
+++ b/src/mangadex/downloader.cr
@@ -1,12 +1,12 @@
require "./api"
-require "zip"
+require "compress/zip"
module MangaDex
class PageJob
property success = false
property url : String
property filename : String
- property writer : Zip::Writer
+ property writer : Compress::Zip::Writer
property tries_remaning : Int32
def initialize(@url, @filename, @writer, @tries_remaning)
@@ -69,7 +69,7 @@ module MangaDex
# Find the number of digits needed to store the number of pages
len = Math.log10(chapter.pages.size).to_i + 1
- writer = Zip::Writer.new zip_path
+ writer = Compress::Zip::Writer.new zip_path
# Create a buffered channel. It works as an FIFO queue
channel = Channel(PageJob).new chapter.pages.size
spawn do
@@ -91,6 +91,7 @@ module MangaDex
end
channel.send page_job
+ break unless @queue.exists? job
end
end
@@ -98,6 +99,9 @@ module MangaDex
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
@@ -110,6 +114,13 @@ module MangaDex
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"
diff --git a/src/mango.cr b/src/mango.cr
index e2bd119..933032b 100644
--- a/src/mango.cr
+++ b/src/mango.cr
@@ -7,7 +7,7 @@ require "option_parser"
require "clim"
require "./plugin/*"
-MANGO_VERSION = "0.16.0"
+MANGO_VERSION = "0.17.0"
# From http://www.network-science.de/ascii/
BANNER = %{
diff --git a/src/plugin/downloader.cr b/src/plugin/downloader.cr
index 31d066d..054698e 100644
--- a/src/plugin/downloader.cr
+++ b/src/plugin/downloader.cr
@@ -53,7 +53,7 @@ class Plugin
end
zip_path = File.join manga_dir, "#{chapter_title}.cbz.part"
- writer = Zip::Writer.new zip_path
+ writer = Compress::Zip::Writer.new zip_path
rescue e
@queue.set_status Queue::JobStatus::Error, job
unless e.message.nil?
@@ -66,6 +66,8 @@ class Plugin
fail_count = 0
while page = plugin.next_page
+ break unless @queue.exists? job
+
fn = process_filename page["filename"].as_s
url = page["url"].as_s
headers = HTTP::Headers.new
@@ -109,6 +111,12 @@ class Plugin
end
end
+ unless @queue.exists? job
+ Logger.debug "Download cancelled"
+ @downloading = false
+ return
+ end
+
Logger.debug "Download completed. #{fail_count}/#{pages} failed"
writer.close
filename = File.join File.dirname(zip_path), File.basename(zip_path,
diff --git a/src/queue.cr b/src/queue.cr
index 0281b61..c9f805c 100644
--- a/src/queue.cr
+++ b/src/queue.cr
@@ -196,6 +196,21 @@ class Queue
self.delete job.id
end
+ def exists?(id : String)
+ res = false
+ MainFiber.run do
+ DB.open "sqlite3://#{@path}" do |db|
+ res = db.query_one "select count(*) from queue where id = (?)", id,
+ as: Bool
+ end
+ end
+ res
+ end
+
+ def exists?(job : Job)
+ self.exists? job.id
+ end
+
def delete_status(status : JobStatus)
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
diff --git a/src/routes/api.cr b/src/routes/api.cr
index ce2caf5..ad4f497 100644
--- a/src/routes/api.cr
+++ b/src/routes/api.cr
@@ -1,9 +1,171 @@
require "./router"
require "../mangadex/*"
require "../upload"
+require "koa"
class APIRouter < Router
+ @@api_json : String?
+
+ API_VERSION = "0.1.0"
+
+ macro s(fields)
+ {
+ {% for field in fields %}
+ {{field}} => "string",
+ {% end %}
+ }
+ end
+
def initialize
+ Koa.init "Mango API", version: API_VERSION, desc: <<-MD
+ # A Word of Caution
+
+ This API was designed for internal use only, and the design doesn't comply with the resources convention of a RESTful API. Because of this, most of the API endpoints listed here will soon be updated and removed in future versions of Mango, so use them at your own risk!
+
+ # Authentication
+
+ All endpoints require authentication. After logging in, your session ID would be stored as a cookie named `mango-sessid-#{Config.current.port}`, which can be used to authenticate the API access. Note that all admin API endpoints (`/api/admin/...`) require the logged-in user to have admin access.
+
+ # Terminologies
+
+ - Entry: An entry is a `cbz`/`cbr` file in your library. Depending on how you organize your manga collection, an entry can contain a chapter, a volume or even an entire manga.
+ - Title: A title contains a list of entries and optionally some sub-titles. For example, you can have a title to store a manga, and it contains a list of sub-titles representing the volumes in the manga. Each sub-title would then contain a list of entries representing the chapters in the volume.
+ - Library: The library is a collection of top-level titles, and it does not contain entries (though the titles do). A Mango instance can only have one library.
+ MD
+
+ Koa.cookie_auth "cookie", "mango-sessid-#{Config.current.port}"
+ Koa.global_tag "admin", desc: <<-MD
+ These are the admin endpoints only accessible for users with admin access. A non-admin user will get HTTP 403 when calling the endpoints.
+ MD
+
+ Koa.binary "binary", desc: "A binary file"
+ Koa.array "entryAry", "$entry", desc: "An array of entries"
+ Koa.array "titleAry", "$title", desc: "An array of titles"
+ Koa.array "strAry", "string", desc: "An array of strings"
+
+ entry_schema = {
+ "pages" => "integer",
+ "mtime" => "integer",
+ }.merge s %w(zip_path title size id title_id display_name cover_url)
+ Koa.object "entry", entry_schema, desc: "An entry in a book"
+
+ title_schema = {
+ "mtime" => "integer",
+ "entries" => "$entryAry",
+ "titles" => "$titleAry",
+ "parents" => "$strAry",
+ }.merge s %w(dir title id display_name cover_url)
+ Koa.object "title", title_schema,
+ desc: "A manga title (a collection of entries and sub-titles)"
+
+ Koa.object "library", {
+ "dir" => "string",
+ "titles" => "$titleAry",
+ }, desc: "A library containing a list of top-level titles"
+
+ Koa.object "scanResult", {
+ "milliseconds" => "integer",
+ "titles" => "integer",
+ }
+
+ Koa.object "progressResult", {
+ "progress" => "number",
+ }
+
+ Koa.object "result", {
+ "success" => "boolean",
+ "error" => "string?",
+ }
+
+ mc_schema = {
+ "groups" => "object",
+ }.merge s %w(id title volume chapter language full_title time manga_title manga_id)
+ Koa.object "mangadexChapter", mc_schema, desc: "A MangaDex chapter"
+
+ Koa.array "chapterAry", "$mangadexChapter"
+
+ mm_schema = {
+ "chapers" => "$chapterAry",
+ }.merge s %w(id title description author artist cover_url)
+ Koa.object "mangadexManga", mm_schema, desc: "A MangaDex manga"
+
+ Koa.object "chaptersObj", {
+ "chapters" => "$chapterAry",
+ }
+
+ Koa.object "successFailCount", {
+ "success" => "integer",
+ "fail" => "integer",
+ }
+
+ job_schema = {
+ "pages" => "integer",
+ "success_count" => "integer",
+ "fail_count" => "integer",
+ "time" => "integer",
+ }.merge s %w(id manga_id title manga_title status_message status)
+ Koa.object "job", job_schema, desc: "A download job in the queue"
+
+ Koa.array "jobAry", "$job"
+
+ Koa.object "jobs", {
+ "success" => "boolean",
+ "paused" => "boolean",
+ "jobs" => "$jobAry",
+ }
+
+ Koa.object "binaryUpload", {
+ "file" => "$binary",
+ }
+
+ Koa.object "pluginListBody", {
+ "plugin" => "string",
+ "query" => "string",
+ }
+
+ Koa.object "pluginChapter", {
+ "id" => "string",
+ "title" => "string",
+ }
+
+ Koa.array "pluginChapterAry", "$pluginChapter"
+
+ Koa.object "pluginList", {
+ "success" => "boolean",
+ "chapters" => "$pluginChapterAry?",
+ "title" => "string?",
+ "error" => "string?",
+ }
+
+ Koa.object "pluginDownload", {
+ "plugin" => "string",
+ "title" => "string",
+ "chapters" => "$pluginChapterAry",
+ }
+
+ Koa.object "dimension", {
+ "width" => "integer",
+ "height" => "integer",
+ }
+
+ Koa.array "dimensionAry", "$dimension"
+
+ Koa.object "dimensionResult", {
+ "success" => "boolean",
+ "dimensions" => "$dimensionAry?",
+ "error" => "string?",
+ }
+
+ Koa.object "ids", {
+ "ids" => "$strAry",
+ }
+
+ Koa.describe "Returns a page in a manga entry"
+ Koa.path "tid", desc: "Title ID"
+ Koa.path "eid", desc: "Entry ID"
+ Koa.path "page", type: "integer", desc: "The page number to return (starts from 1)"
+ Koa.response 200, ref: "$binary", media_type: "image/*"
+ Koa.response 500, "Page not found or not readable"
get "/api/page/:tid/:eid/:page" do |env|
begin
tid = env.params.url["tid"]
@@ -26,6 +188,11 @@ class APIRouter < Router
end
end
+ Koa.describe "Returns the cover image of a manga entry"
+ Koa.path "tid", desc: "Title ID"
+ Koa.path "eid", desc: "Entry ID"
+ Koa.response 200, ref: "$binary", media_type: "image/*"
+ Koa.response 500, "Page not found or not readable"
get "/api/cover/:tid/:eid" do |env|
begin
tid = env.params.url["tid"]
@@ -48,6 +215,10 @@ class APIRouter < Router
end
end
+ Koa.describe "Returns the book with title `tid`"
+ Koa.path "tid", desc: "Title ID"
+ Koa.response 200, ref: "$title"
+ Koa.response 404, "Title not found"
get "/api/book/:tid" do |env|
begin
tid = env.params.url["tid"]
@@ -57,15 +228,20 @@ class APIRouter < Router
send_json env, title.to_json
rescue e
@context.error e
- env.response.status_code = 500
+ env.response.status_code = 404
e.message
end
end
- get "/api/book" do |env|
+ Koa.describe "Returns the entire library with all titles and entries"
+ Koa.response 200, ref: "$library"
+ get "/api/library" do |env|
send_json env, @context.library.to_json
end
+ Koa.describe "Triggers a library scan"
+ Koa.tag "admin"
+ Koa.response 200, ref: "$scanResult"
post "/api/admin/scan" do |env|
start = Time.utc
@context.library.scan
@@ -76,19 +252,27 @@ class APIRouter < Router
}.to_json
end
+ Koa.describe "Returns the thumbnail generation progress between 0 and 1"
+ Koa.tag "admin"
+ Koa.response 200, ref: "$progressResult"
get "/api/admin/thumbnail_progress" do |env|
send_json env, {
"progress" => Library.default.thumbnail_generation_progress,
}.to_json
end
+ Koa.describe "Triggers a thumbnail generation"
+ Koa.tag "admin"
post "/api/admin/generate_thumbnails" do |env|
spawn do
Library.default.generate_thumbnails
end
end
- post "/api/admin/user/delete/:username" do |env|
+ Koa.describe "Deletes a user with `username`"
+ Koa.tag "admin"
+ Koa.response 200, ref: "$result"
+ delete "/api/admin/user/delete/:username" do |env|
begin
username = env.params.url["username"]
@context.storage.delete_user username
@@ -103,13 +287,24 @@ class APIRouter < Router
end
end
- post "/api/progress/:title/:page" do |env|
+ Koa.describe "Updates the reading progress of an entry or the whole title for the current user", <<-MD
+ When `eid` is provided, sets the reading progress of the entry to `page`.
+
+ When `eid` is omitted, updates the progress of the entire title. Specifically:
+
+ - if `page` is 0, marks the entire title as unread
+ - otherwise, marks the entire title as read
+ MD
+ Koa.path "tid", desc: "Title ID"
+ Koa.query "eid", desc: "Entry ID", required: false
+ Koa.path "page", desc: "The new page number indicating the progress"
+ Koa.response 200, ref: "$result"
+ put "/api/progress/:tid/:page" do |env|
begin
username = get_username env
- title = (@context.library.get_title env.params.url["title"])
- .not_nil!
+ title = (@context.library.get_title env.params.url["tid"]).not_nil!
page = env.params.url["page"].to_i
- entry_id = env.params.query["entry"]?
+ entry_id = env.params.query["eid"]?
if !entry_id.nil?
entry = title.get_entry(entry_id).not_nil!
@@ -131,10 +326,15 @@ class APIRouter < Router
end
end
- post "/api/bulk-progress/:action/:title" do |env|
+ Koa.describe "Updates the reading progress of multiple entries in a title"
+ Koa.path "action", desc: "The action to perform. Can be either `read` or `unread`"
+ Koa.path "tid", desc: "Title ID"
+ Koa.body ref: "$ids", desc: "An array of entry IDs"
+ Koa.response 200, ref: "$result"
+ put "/api/bulk_progress/:action/:tid" do |env|
begin
username = get_username env
- title = (@context.library.get_title env.params.url["title"]).not_nil!
+ title = (@context.library.get_title env.params.url["tid"]).not_nil!
action = env.params.url["action"]
ids = env.params.json["ids"].as(Array).map &.as_s
@@ -153,12 +353,20 @@ class APIRouter < Router
end
end
- post "/api/admin/display_name/:title/:name" do |env|
+ Koa.describe "Sets the display name of a title or an entry", <<-MD
+ When `eid` is provided, apply the display name to the entry. Otherwise, apply the display name to the title identified by `tid`.
+ MD
+ Koa.tag "admin"
+ Koa.path "tid", desc: "Title ID"
+ Koa.query "eid", desc: "Entry ID", required: false
+ Koa.path "name", desc: "The new display name"
+ Koa.response 200, ref: "$result"
+ put "/api/admin/display_name/:tid/:name" do |env|
begin
- title = (@context.library.get_title env.params.url["title"])
+ title = (@context.library.get_title env.params.url["tid"])
.not_nil!
name = env.params.url["name"]
- entry = env.params.query["entry"]?
+ entry = env.params.query["eid"]?
if entry.nil?
title.set_display_name name
else
@@ -176,6 +384,12 @@ class APIRouter < Router
end
end
+ Koa.describe "Returns a MangaDex manga identified by `id`", <<-MD
+ On error, returns a JSON that contains the error message in the `error` field.
+ MD
+ Koa.tag "admin"
+ Koa.path "id", desc: "A MangaDex manga ID"
+ Koa.response 200, ref: "$mangadexManga"
get "/api/admin/mangadex/manga/:id" do |env|
begin
id = env.params.url["id"]
@@ -188,6 +402,12 @@ class APIRouter < Router
end
end
+ Koa.describe "Adds a list of MangaDex chapters to the download queue", <<-MD
+ On error, returns a JSON that contains the error message in the `error` field.
+ MD
+ Koa.tag "admin"
+ Koa.body ref: "$chaptersObj"
+ Koa.response 200, ref: "$successFailCount"
post "/api/admin/mangadex/download" do |env|
begin
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
@@ -212,6 +432,23 @@ class APIRouter < Router
end
end
+ ws "/api/admin/mangadex/queue" do |socket, env|
+ interval_raw = env.params.query["interval"]?
+ interval = (interval_raw.to_i? if interval_raw) || 5
+ loop do
+ socket.send({
+ "jobs" => @context.queue.get_all,
+ "paused" => @context.queue.paused?,
+ }.to_json)
+ sleep interval.seconds
+ end
+ end
+
+ Koa.describe "Returns the current download queue", <<-MD
+ On error, returns a JSON that contains the error message in the `error` field.
+ MD
+ Koa.tag "admin"
+ Koa.response 200, ref: "$jobs"
get "/api/admin/mangadex/queue" do |env|
begin
jobs = @context.queue.get_all
@@ -228,6 +465,19 @@ class APIRouter < Router
end
end
+ Koa.describe "Perform an action on a download job or all jobs in the queue", <<-MD
+ The `action` parameter can be `delete`, `retry`, `pause` or `resume`.
+
+ When `action` is `pause` or `resume`, pauses or resumes the download queue, respectively.
+
+ When `action` is set to `delete`, the behavior depends on `id`. If `id` is provided, deletes the specific job identified by the ID. Otherwise, deletes all **completed** jobs in the queue.
+
+ When `action` is set to `retry`, the behavior depends on `id`. If `id` is provided, restarts the job identified by the ID. Otherwise, retries all jobs in the `Error` or `MissingPages` status in the queue.
+ MD
+ Koa.tag "admin"
+ Koa.path "action", desc: "The action to perform. It should be one of the followins: `delete`, `retry`, `pause` and `resume`."
+ Koa.query "id", required: false, desc: "A job ID"
+ Koa.response 200, ref: "$result"
post "/api/admin/mangadex/queue/:action" do |env|
begin
action = env.params.url["action"]
@@ -262,6 +512,22 @@ class APIRouter < Router
end
end
+ Koa.describe "Uploads a file to the server", <<-MD
+ Currently the only supported value for the `target` parameter is `cover`.
+
+ ### Cover
+
+ Uploads a cover image for a title or an entry.
+
+ Query parameters:
+ - `tid`: A title ID
+ - `eid`: (Optional) An entry ID
+
+ When `eid` is omitted, the new cover image will be applied to the title. Otherwise, applies the image to the specified entry.
+ MD
+ Koa.tag "admin"
+ Koa.body type: "multipart/form-data", ref: "$binaryUpload"
+ Koa.response 200, ref: "$result"
post "/api/admin/upload/:target" do |env|
begin
target = env.params.url["target"]
@@ -276,8 +542,8 @@ class APIRouter < Router
case target
when "cover"
- title_id = env.params.query["title"]
- entry_id = env.params.query["entry"]?
+ title_id = env.params.query["tid"]
+ entry_id = env.params.query["eid"]?
title = @context.library.get_title(title_id).not_nil!
unless SUPPORTED_IMG_TYPES.includes? \
@@ -316,10 +582,14 @@ class APIRouter < Router
end
end
- post "/api/admin/plugin/list" do |env|
+ Koa.describe "Lists the chapters in a title from a plugin"
+ Koa.tag "admin"
+ Koa.body ref: "$pluginListBody"
+ Koa.response 200, ref: "$pluginList"
+ get "/api/admin/plugin/list" do |env|
begin
- query = env.params.json["query"].as String
- plugin = Plugin.new env.params.json["plugin"].as String
+ query = env.params.query["query"].as String
+ plugin = Plugin.new env.params.query["plugin"].as String
json = plugin.list_chapters query
chapters = json["chapters"]
@@ -338,6 +608,10 @@ class APIRouter < Router
end
end
+ Koa.describe "Adds a list of chapters from a plugin to the download queue"
+ Koa.tag "admin"
+ Koa.body ref: "$pluginDownload"
+ Koa.response 200, ref: "$successFailCount"
post "/api/admin/plugin/download" do |env|
begin
plugin = Plugin.new env.params.json["plugin"].as String
@@ -367,6 +641,10 @@ class APIRouter < Router
end
end
+ Koa.describe "Returns the image dimensions of all pages in an entry"
+ Koa.path "tid", desc: "A title ID"
+ Koa.path "eid", desc: "An entry ID"
+ Koa.response 200, ref: "$dimensionResult"
get "/api/dimensions/:tid/:eid" do |env|
begin
tid = env.params.url["tid"]
@@ -389,5 +667,33 @@ class APIRouter < Router
}.to_json
end
end
+
+ Koa.describe "Downloads an entry"
+ Koa.path "tid", desc: "A title ID"
+ Koa.path "eid", desc: "An entry ID"
+ Koa.response 200, ref: "$binary"
+ Koa.response 404, "Entry not found"
+ get "/api/download/:tid/:eid" do |env|
+ begin
+ title = (@context.library.get_title env.params.url["tid"]).not_nil!
+ entry = (title.get_entry env.params.url["eid"]).not_nil!
+
+ send_attachment env, entry.zip_path
+ rescue e
+ @context.error e
+ env.response.status_code = 404
+ end
+ end
+
+ doc = Koa.generate
+ @@api_json = doc.to_json if doc
+
+ get "/openapi.json" do |env|
+ if @@api_json
+ send_json env, @@api_json
+ else
+ env.response.status_code = 404
+ end
+ end
end
end
diff --git a/src/routes/main.cr b/src/routes/main.cr
index 4c55472..cb919cf 100644
--- a/src/routes/main.cr
+++ b/src/routes/main.cr
@@ -113,5 +113,9 @@ class MainRouter < Router
env.response.status_code = 500
end
end
+
+ get "/api" do |env|
+ render "src/views/api.html.ecr"
+ end
end
end
diff --git a/src/routes/opds.cr b/src/routes/opds.cr
index 567931e..eafbcb9 100644
--- a/src/routes/opds.cr
+++ b/src/routes/opds.cr
@@ -16,17 +16,5 @@ class OPDSRouter < Router
env.response.status_code = 404
end
end
-
- get "/opds/download/:title/:entry" do |env|
- begin
- title = (@context.library.get_title env.params.url["title"]).not_nil!
- entry = (title.get_entry env.params.url["entry"]).not_nil!
-
- send_attachment env, entry.zip_path
- rescue e
- @context.error e
- env.response.status_code = 404
- end
- end
end
end
diff --git a/src/util/proxy.cr b/src/util/proxy.cr
index 2325419..88316be 100644
--- a/src/util/proxy.cr
+++ b/src/util/proxy.cr
@@ -35,7 +35,8 @@ private def env_to_proxy(key : String) : HTTP::Proxy::Client?
begin
uri = URI.parse val
- HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil!
+ HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil!,
+ username: uri.user, password: uri.password
rescue
nil
end
diff --git a/src/util/util.cr b/src/util/util.cr
index 9019720..3fc1b2d 100644
--- a/src/util/util.cr
+++ b/src/util/util.cr
@@ -61,3 +61,9 @@ class String
self.chars.all? { |c| c.alphanumeric? || c == '_' }
end
end
+
+def env_is_true?(key : String) : Bool
+ val = ENV[key.upcase]? || ENV[key.downcase]?
+ return false unless val
+ val.downcase.in? "1", "true"
+end
diff --git a/src/util/web.cr b/src/util/web.cr
index 1bd38ec..c384099 100644
--- a/src/util/web.cr
+++ b/src/util/web.cr
@@ -85,9 +85,14 @@ end
module HTTP
class Client
private def self.exec(uri : URI, tls : TLSContext = nil)
- Logger.debug "Setting read timeout"
previous_def uri, tls do |client, path|
+ if client.tls? && env_is_true? "DISABLE_SSL_VERIFICATION"
+ Logger.debug "Disabling SSL verification"
+ client.tls.verify_mode = OpenSSL::SSL::VerifyMode::NONE
+ end
+ Logger.debug "Setting read timeout"
client.read_timeout = Config.current.download_timeout_seconds.seconds
+ Logger.debug "Requesting #{uri}"
yield client, path
end
end
diff --git a/src/views/api.html.ecr b/src/views/api.html.ecr
new file mode 100644
index 0000000..5bfdfa4
--- /dev/null
+++ b/src/views/api.html.ecr
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Mango API Documentation
+
+
+
+
+
+
+
+
diff --git a/src/views/components/head.html.ecr b/src/views/components/head.html.ecr
index d2421d0..c65ddea 100644
--- a/src/views/components/head.html.ecr
+++ b/src/views/components/head.html.ecr
@@ -14,5 +14,5 @@
-
+
diff --git a/src/views/download-manager.html.ecr b/src/views/download-manager.html.ecr
index ec8618a..db919c1 100644
--- a/src/views/download-manager.html.ecr
+++ b/src/views/download-manager.html.ecr
@@ -1,32 +1,68 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ Chapter |
+ Manga |
+ Progress |
+ Time |
+ Status |
+ Plugin |
+ Actions |
+
+
+
+
+
+
+
+ |
+
+
+ |
+
+
+
+ |
+
+
+ |
+
+
+ |
+ |
+
+
+
+
+
+
+ |
+
+ |
+
+
+
+
+
+
+ |
+
+
+
+
-
-
-
- Chapter |
- Manga |
- Progress |
- Time |
- Status |
- Plugin |
- Actions |
-
-
-
<% content_for "script" do %>
-
diff --git a/src/views/opds/title.xml.ecr b/src/views/opds/title.xml.ecr
index 392978c..b159687 100644
--- a/src/views/opds/title.xml.ecr
+++ b/src/views/opds/title.xml.ecr
@@ -29,7 +29,7 @@
-
+
diff --git a/src/views/reader.html.ecr b/src/views/reader.html.ecr
index 540fd0c..9136071 100644
--- a/src/views/reader.html.ecr
+++ b/src/views/reader.html.ecr
@@ -39,7 +39,7 @@
:width="item.width"
:height="item.height"
:id="item.id"
- @click="showControl($event)"
+ :onclick="`showControl('${item.id}')`"
/>
<%- if next_entry_url -%>
@@ -55,7 +55,7 @@
'uk-align-center': true,
'uk-animation-slide-left': flipAnimation === 'left',
'uk-animation-slide-right': flipAnimation === 'right'
- }" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="showControl($event)" :style="`
+ }" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" :onclick="`showControl('${curItem.id}')`" :style="`
width:${mode === 'width' ? '100vw' : 'auto'};
height:${mode === 'height' ? '100vh' : 'auto'};
margin-bottom:0;