Merge branch 'master' into v0.2.0

This commit is contained in:
Alex Ling 2020-02-26 18:19:30 +00:00
commit 319967438b
13 changed files with 165 additions and 61 deletions

32
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,32 @@
---
name: Bug report
about: I found a bug in Mango!
title: "[Bug Report]"
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Environment (please complete the following information):**
- OS: [e.g. Ubuntu 18.04]
- Browser [e.g. chrome, safari, if applicable]
- Mango Version [e.g. v0.1.0]
**Docker (if you are running Mango in a Docker container)**
- The `docker-compose.yml` file you are using
**Additional context**
Add any other context about the problem here. Add screenshots if applicable.

View File

@ -0,0 +1,17 @@
---
name: Feature request
about: Suggest a feature for Mango
title: "[Feature Request]"
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@ -0,0 +1,10 @@
---
name: General Question
about: I have a question about Mango
title: "[Question]"
labels: general question
assignees: ''
---

View File

@ -12,18 +12,22 @@ Mango is a self-hosted manga server and reader. Its features include
## Installation
### Pre-built Binary
1. Simply download the pre-built binary file `mango` for the latest [release](https://github.com/hkalexling/Mango/releases). All the dependencies are statically linked, and it should work with most Linux systems on amd64.
### Docker
1. Make sure you have docker installed and running. You will also need `docker-compose`
2. Clone the repository
3. Copy `docker-compose.example.yml` to `docker-compose.yml` and modify it if necessary
4. Run `docker-compose up`. This should build the docker image and start the container with Mango running inside
5. Head over to `localhost:9000` to log in
3. Copy `docker-compose.example.yml` to `docker-compose.yml`
4. Modify the `volumes` in `docker-compose.yml` to point the directories to desired locations on the host machine
5. Run `docker-compose up`. This should build the docker image and start the container with Mango running inside
6. Head over to `localhost:9000` to log in
### Build from source
1. Make sure you have Crystal, Node and Yarn installed
1. Make sure you have Crystal, Node and Yarn installed. You might also need to install the development headers for `libsqlite3` and `libyaml`.
2. Clone the repository
3. `make && sudo make install`
4. Start Mango by running the command `mango`
@ -43,7 +47,7 @@ Mango e-manga server/reader. Version 0.1.0
### Config
The default config file location is `~/.config/mango/config.yml`. The config options and default values are given below
The default config file location is `~/.config/mango/config.yml`. It might be different if you are running Mango in a docker container. The config options and default values are given below
```yaml
---

View File

@ -11,8 +11,5 @@ services:
ports:
- 9000:9000
volumes:
- library:/root/mango/library
- config:/root/.config/mango
volumes:
library:
config:
- ~/mango:/root/mango
- ~/.config/mango:/root/.config/mango

View File

@ -1,4 +1,7 @@
function showModal(title, zipPath, pages, percentage, title, entry) {
function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTitle, titleID, entryID) {
const zipPath = decodeURIComponent(encodedPath);
const title = decodeURIComponent(encodedeTitle);
const entry = decodeURIComponent(encodedEntryTitle);
$('#modal button, #modal a').each(function(){
$(this).removeAttr('hidden');
});
@ -16,20 +19,20 @@ function showModal(title, zipPath, pages, percentage, title, entry) {
$('#path-text').text(zipPath);
$('#pages-text').text(pages + ' pages');
$('#beginning-btn').attr('href', '/reader/' + title + '/' + entry + '/1');
$('#continue-btn').attr('href', '/reader/' + title + '/' + entry);
$('#beginning-btn').attr('href', '/reader/' + titleID + '/' + entryID + '/1');
$('#continue-btn').attr('href', '/reader/' + titleID + '/' + entryID);
$('#read-btn').click(function(){
updateProgress(title, entry, pages);
updateProgress(titleID, entryID, pages);
});
$('#unread-btn').click(function(){
updateProgress(title, entry, 0);
updateProgress(titleID, entryID, 0);
});
UIkit.modal($('#modal')).show();
}
function updateProgress(title, entry, page) {
$.post('/api/progress/' + title + '/' + entry + '/' + page, function(data) {
function updateProgress(titleID, entryID, page) {
$.post('/api/progress/' + titleID + '/' + entryID + '/' + page, function(data) {
if (data.success) {
location.reload();
}

View File

@ -1,6 +1,7 @@
require "zip"
require "mime"
require "json"
require "uri"
struct Image
property data : Bytes
@ -13,12 +14,16 @@ struct Image
end
class Entry
JSON.mapping zip_path: String, book_title: String, title: String, \
size: String, pages: Int32, cover_url: String, mtime: Time
JSON.mapping zip_path: String, book_title: String, title: String,
size: String, pages: Int32, cover_url: String, id: String,
title_id: String, encoded_path: String, encoded_title: String,
mtime: Time
def initialize(path, @book_title)
def initialize(path, @book_title, @title_id, storage)
@zip_path = path
@encoded_path = URI.encode path
@title = File.basename path, File.extname path
@encoded_title = URI.encode @title
@size = (File.size path).humanize_bytes
file = Zip::File.new path
@pages = file.entries
@ -28,7 +33,8 @@ class Entry
}
.size
file.close
@cover_url = "/api/page/#{@book_title}/#{title}/1"
@id = storage.get_id @zip_path, false
@cover_url = "/api/page/#{@title_id}/#{@id}/1"
@mtime = File.info(@zip_path).modification_time
end
def read_page(page_num)
@ -54,23 +60,30 @@ class Entry
end
class Title
JSON.mapping dir: String, entries: Array(Entry), title: String, mtime: Time
JSON.mapping dir: String, entries: Array(Entry), title: String,
id: String, encoded_title: String, mtime: Time
def initialize(dir : String)
def initialize(dir : String, storage)
@dir = dir
@id = storage.get_id @dir, true
@title = File.basename dir
@encoded_title = URI.encode @title
@entries = (Dir.entries dir)
.select { |path| [".zip", ".cbz"].includes? File.extname path }
.map { |path| Entry.new File.join(dir, path), @title }
.map { |path|
Entry.new File.join(dir, path), @title, @id, storage
}
.select { |e| e.pages > 0 }
.sort { |a, b| a.title <=> b.title }
mtimes = [File.info(dir).modification_time]
mtimes += @entries.map{|e| e.mtime}
@mtime = mtimes.max
end
def get_entry(name)
@entries.find { |e| e.title == name }
def get_entry(eid)
@entries.find { |e| e.id == eid }
end
# For backward backward compatibility with v0.1.0, we save entry titles
# instead of IDs in info.json
def save_progress(username, entry, page)
info = TitleInfo.new @dir
if info.progress[username]?.nil?
@ -81,7 +94,7 @@ class Title
info.progress[username][entry] = page
info.save @dir
end
def load_progress(username, entry : String)
def load_progress(username, entry)
info = TitleInfo.new @dir
if info.progress[username]?.nil?
return 0
@ -91,10 +104,10 @@ class Title
end
info.progress[username][entry]
end
def load_percetage(username, entry : String)
def load_percetage(username, entry)
info = TitleInfo.new @dir
page = load_progress username, entry
entry_obj = get_entry entry
entry_obj = @entries.find{|e| e.title == entry}
return 0 if entry_obj.nil?
page / entry_obj.pages
end
@ -142,9 +155,10 @@ class TitleInfo
end
class Library
JSON.mapping dir: String, titles: Array(Title), scan_interval: Int32, logger: MLogger
JSON.mapping dir: String, titles: Array(Title), scan_interval: Int32,
logger: MLogger, storage: Storage
def initialize(@dir, @scan_interval, @logger)
def initialize(@dir, @scan_interval, @logger, @storage)
# explicitly initialize @titles to bypass the compiler check. it will
# be filled with actual Titles in the `scan` call below
@titles = [] of Title
@ -160,8 +174,8 @@ class Library
end
end
end
def get_title(name)
@titles.find { |t| t.title == name }
def get_title(tid)
@titles.find { |t| t.id == tid }
end
def scan
unless Dir.exists? @dir
@ -171,7 +185,7 @@ class Library
end
@titles = (Dir.entries @dir)
.select { |path| File.directory? File.join @dir, path }
.map { |path| Title.new File.join @dir, path }
.map { |path| Title.new File.join(@dir, path), @storage }
.select { |title| !title.entries.empty? }
.sort { |a, b| a.title <=> b.title }
@logger.debug "Scan completed"

View File

@ -25,8 +25,8 @@ end
config = Config.load config_path
logger = MLogger.new config
library = Library.new config.library_path, config.scan_interval, logger
storage = Storage.new config.db_path, logger
library = Library.new config.library_path, config.scan_interval, logger, storage
context = Context.new config, logger, library, storage

View File

@ -3,19 +3,20 @@ require "../mangadex/*"
class APIRouter < Router
def setup
get "/api/page/:title/:entry/:page" do |env|
get "/api/page/:tid/:eid/:page" do |env|
begin
title = env.params.url["title"]
entry = env.params.url["entry"]
tid = env.params.url["tid"]
eid = env.params.url["eid"]
page = env.params.url["page"].to_i
t = @context.library.get_title title
raise "Title `#{title}` not found" if t.nil?
e = t.get_entry entry
raise "Entry `#{entry}` of `#{title}` not found" if e.nil?
img = e.read_page page
raise "Failed to load page #{page} of `#{title}/#{entry}`"\
if img.nil?
title = @context.library.get_title tid
raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if \
entry.nil?
img = entry.read_page page
raise "Failed to load page #{page} of " \
"`#{title.title}/#{entry.title}`" if img.nil?
send_img env, img
rescue e
@ -27,12 +28,11 @@ class APIRouter < Router
get "/api/book/:title" do |env|
begin
title = env.params.url["title"]
tid = env.params.url["tid"]
title = @context.library.get_title tid
raise "Title ID `#{tid}` not found" if title.nil?
t = @context.library.get_title title
raise "Title `#{title}` not found" if t.nil?
send_json env, t.to_json
send_json env, title.to_json
rescue e
@context.error e
env.response.status_code = 500

View File

@ -16,7 +16,7 @@ class ReaderRouter < Router
# might not have actually read them
page = [page - 2 * IMGS_PER_PAGE, 1].max
env.redirect "/reader/#{title.title}/#{entry.title}/#{page}"
env.redirect "/reader/#{title.id}/#{entry.id}/#{page}"
rescue e
@context.error e
env.response.status_code = 404
@ -37,16 +37,16 @@ class ReaderRouter < Router
pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min)
urls = pages.map { |idx|
"/api/page/#{title.title}/#{entry.title}/#{idx}" }
"/api/page/#{title.id}/#{entry.id}/#{idx}" }
reader_urls = pages.map { |idx|
"/reader/#{title.title}/#{entry.title}/#{idx}" }
"/reader/#{title.id}/#{entry.id}/#{idx}" }
next_page = page + IMGS_PER_PAGE
next_url = next_page > entry.pages ? nil :
"/reader/#{title.title}/#{entry.title}/#{next_page}"
exit_url = "/book/#{title.title}"
"/reader/#{title.id}/#{entry.id}/#{next_page}"
exit_url = "/book/#{title.id}"
next_entry = title.next_entry entry
next_entry_url = next_entry.nil? ? nil : \
"/reader/#{title.title}/#{next_entry.title}"
"/reader/#{title.id}/#{next_entry.id}"
render "src/views/reader.ecr"
rescue e

View File

@ -12,7 +12,7 @@ def verify_password(hash, pw)
end
def random_str
Base64.strict_encode UUID.random().to_s
UUID.random.to_s.gsub "-", ""
end
class Storage
@ -25,10 +25,18 @@ class Storage
end
DB.open "sqlite3://#{path}" do |db|
begin
# We create the `ids` table first. even if the uses has an
# early version installed and has the `user` table only,
# we will still be able to create `ids`
db.exec "create table ids" \
"(path text, id text, is_title integer)"
db.exec "create unique index path_idx on ids (path)"
db.exec "create unique index id_idx on ids (id)"
db.exec "create table users" \
"(username text, password text, token text, admin integer)"
rescue e
unless e.message == "table users already exists"
unless e.message.not_nil!.ends_with? "already exists"
@logger.fatal "Error when checking tables in DB: #{e}"
raise e
end
@ -147,4 +155,23 @@ class Storage
end
end
end
def get_id(path, is_title)
DB.open "sqlite3://#{@path}" do |db|
begin
id = db.query_one "select id from ids where path = (?)",
path, as: {String}
return id
rescue
id = random_str
db.exec "insert into ids values (?, ?, ?)", path, id,
is_title ? 1 : 0
return id
end
end
end
def to_json(json : JSON::Builder)
json.string self
end
end

View File

@ -23,7 +23,7 @@
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- titles.each_with_index do |t, i| -%>
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="<%= percentage[i] %>">
<a class="acard" href="/book/<%= t.title %>">
<a class="acard" href="/book/<%= t.id %>">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top">
<img src="<%= t.entries[0].cover_url %>" alt="">

View File

@ -25,7 +25,7 @@
<%- title.entries.each_with_index do |e, i| -%>
<div class="item" data-mtime="<%= e.mtime.to_unix %>" data-progress="<%= percentage[i] %>">
<a class="acard">
<div class="uk-card uk-card-default" onclick="showModal('<%= e.title %>', '<%= e.zip_path %>', '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, '<%= title.title %>', '<%= e.title %>')">
<div class="uk-card uk-card-default" onclick="showModal(&quot;<%= e.encoded_path %>&quot;, '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, &quot;<%= title.encoded_title %>&quot;, &quot;<%= e.encoded_title %>&quot;, '<%= e.title_id %>', '<%= e.id %>')">
<div class="uk-card-media-top">
<img src="<%= e.cover_url %>" alt="">
</div>