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