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 ## 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 ### Docker
1. Make sure you have docker installed and running. You will also need `docker-compose` 1. Make sure you have docker installed and running. You will also need `docker-compose`
2. Clone the repository 2. Clone the repository
3. Copy `docker-compose.example.yml` to `docker-compose.yml` and modify it if necessary 3. Copy `docker-compose.example.yml` to `docker-compose.yml`
4. Run `docker-compose up`. This should build the docker image and start the container with Mango running inside 4. Modify the `volumes` in `docker-compose.yml` to point the directories to desired locations on the host machine
5. Head over to `localhost:9000` to log in 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 ### 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 2. Clone the repository
3. `make && sudo make install` 3. `make && sudo make install`
4. Start Mango by running the command `mango` 4. Start Mango by running the command `mango`
@ -43,7 +47,7 @@ Mango e-manga server/reader. Version 0.1.0
### Config ### 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 ```yaml
--- ---

View File

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

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

View File

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

View File

@ -25,8 +25,8 @@ end
config = Config.load config_path config = Config.load config_path
logger = MLogger.new config logger = MLogger.new config
library = Library.new config.library_path, config.scan_interval, logger
storage = Storage.new config.db_path, 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 context = Context.new config, logger, library, storage

View File

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

View File

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

View File

@ -12,7 +12,7 @@ def verify_password(hash, pw)
end end
def random_str def random_str
Base64.strict_encode UUID.random().to_s UUID.random.to_s.gsub "-", ""
end end
class Storage class Storage
@ -25,10 +25,18 @@ class Storage
end end
DB.open "sqlite3://#{path}" do |db| DB.open "sqlite3://#{path}" do |db|
begin 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" \ db.exec "create table users" \
"(username text, password text, token text, admin integer)" "(username text, password text, token text, admin integer)"
rescue e 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}" @logger.fatal "Error when checking tables in DB: #{e}"
raise e raise e
end end
@ -147,4 +155,23 @@ class Storage
end end
end 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 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> <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| -%> <%- titles.each_with_index do |t, i| -%>
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="<%= percentage[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 uk-card-default">
<div class="uk-card-media-top"> <div class="uk-card-media-top">
<img src="<%= t.entries[0].cover_url %>" alt=""> <img src="<%= t.entries[0].cover_url %>" alt="">

View File

@ -25,7 +25,7 @@
<%- title.entries.each_with_index do |e, i| -%> <%- title.entries.each_with_index do |e, i| -%>
<div class="item" data-mtime="<%= e.mtime.to_unix %>" data-progress="<%= percentage[i] %>"> <div class="item" data-mtime="<%= e.mtime.to_unix %>" data-progress="<%= percentage[i] %>">
<a class="acard"> <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"> <div class="uk-card-media-top">
<img src="<%= e.cover_url %>" alt=""> <img src="<%= e.cover_url %>" alt="">
</div> </div>