Compare commits

..

30 Commits

Author SHA1 Message Date
Alex Ling
aa147602fc Bump version number 0.2.2 -> 0.2.3 2020-03-27 05:00:14 +00:00
Alex Ling
d58c83fbd8 Use BigInt when sorting filenames (#22) 2020-03-27 04:45:03 +00:00
Alex Ling
1a0c3d81ce Add Patreon 2020-03-21 05:18:53 +00:00
Alex Ling
33c61fd8c1 Add build badge 2020-03-19 16:04:06 -04:00
Alex Ling
6eba3fe351 Create build.yml 2020-03-19 19:58:59 +00:00
Alex Ling
da2708abe5 Put mango binary in / instead of /root/Mango/ 2020-03-19 18:17:26 +00:00
Alex Ling
febf344d33 Remove unnecessary libraries 2020-03-19 18:16:48 +00:00
Alex Ling
ae15398b6c Name the builder stage 2020-03-19 18:14:02 +00:00
Alex Ling
b28f6046dd Merge pull request #17 from WROIATE/master
Update Dockerfile to reduce the image size
2020-03-19 12:29:19 -04:00
Jarao
91b823450c Update Dockerfile 2020-03-19 13:00:11 +08:00
Alex Ling
085fba611c Update README.md 2020-03-17 11:59:32 -04:00
Alex Ling
f8d633c751 Add example library structure to README 2020-03-17 11:45:46 -04:00
Alex Ling
f5e6f42fc2 Update README.md 2020-03-15 13:16:19 -04:00
Alex Ling
3ca6d3d338 Bump version (0.2.0 -> 0.2.1) 2020-03-15 17:09:27 +00:00
Alex Ling
750a28eccb Break words in modal title and path to handle long text 2020-03-15 02:58:27 +00:00
Alex Ling
88b16445e2 Show entry title instead of book title in modal 2020-03-15 02:55:35 +00:00
Alex Ling
7774efa471 When a title has no entry as immediate child, always return 0 as the reading progress 2020-03-15 02:30:18 +00:00
Alex Ling
4aeda53806 Sort title_ids and entries alphanumerically 2020-03-15 02:29:45 +00:00
Alex Ling
5d62a87720 Fix inaccurate sorting when sorting by progress 2020-03-15 02:28:21 +00:00
Alex Ling
e902e1dff0 Merge branch 'nested' into v0.2.1 2020-03-15 02:15:55 +00:00
Alex Ling
9fe32b5011 When a title contains no entry as immediate child, display mango logo and remove progress badge 2020-03-15 02:10:22 +00:00
Alex Ling
e65d701e0a Show sum of entries and titles count when displaying the number of entries 2020-03-15 02:08:20 +00:00
Alex Ling
5a500364fc Show a list of parent directories on the title page 2020-03-15 01:45:10 +00:00
Alex Ling
3e42266955 List the parent title objects in Title.to_json 2020-03-15 01:31:14 +00:00
Alex Ling
6407cea7bf Refactor src/library.cr to reduce memory usage
- Store the `Title` objects in `Library@title_hash`
- The `Title` objects only stores IDs to other titles
2020-03-15 01:05:37 +00:00
Alex Ling
7e22cc5f57 Fix bug in API /api/book/:tid that causes 500 2020-03-15 01:03:49 +00:00
Alex Ling
e68678f2fb Remove unnecessary JSON::Field calls 2020-03-14 23:59:46 +00:00
Alex Ling
82fb45b242 Use json builder in src/library.cr instead of json mapping 2020-03-14 23:58:49 +00:00
Alex Ling
46dfc2f712 Set login cookie expiration date 2020-03-14 22:53:52 +00:00
Alex Ling
8c7ced87f1 Add nested library support (WIP) 2020-03-12 20:37:03 +00:00
16 changed files with 238 additions and 61 deletions

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
patreon: hkalexling

24
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: Build
on:
push:
branches: [ master, dev ]
pull_request:
branches: [ master, dev ]
jobs:
build:
runs-on: ubuntu-latest
container:
image: crystallang/crystal:0.32.1-alpine
steps:
- uses: actions/checkout@v2
- name: Install dependencies
run: apk add --no-cache yarn yaml sqlite-static
- name: Build
run: make
- name: Run tests
run: make test

View File

@@ -1,18 +1,16 @@
FROM crystallang/crystal:0.32.0
RUN apt-get update && apt-get install -y curl
RUN curl -sL https://deb.nodesource.com/setup_10.x | bash -
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
RUN apt-get update && apt-get install -y nodejs yarn libsqlite3-dev
FROM crystallang/crystal:0.32.1-alpine AS builder
WORKDIR /Mango
COPY . .
COPY package*.json .
RUN apk add --no-cache yarn yaml sqlite-static \
&& make static
RUN make && make install
FROM library/alpine
CMD ["mango"]
WORKDIR /
COPY --from=builder /Mango/mango .
CMD ["./mango"]

View File

@@ -5,13 +5,14 @@
# Mango
[![Gitter](https://badges.gitter.im/mango-cr/mango.svg)](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
[![Patreon](https://img.shields.io/badge/support-patreon-brightgreen?link=https://www.patreon.com/hkalexling)](https://www.patreon.com/hkalexling) ![Build](https://github.com/hkalexling/Mango/workflows/Build/badge.svg) [![Gitter](https://badges.gitter.im/mango-cr/mango.svg)](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
Mango is a self-hosted manga server and reader. Its features include
- Multi-user support
- Dark/light mode switch
- Supports both `.zip` and `.cbz` formats
- Supports nested folders in library
- Automatically stores reading progress
- Built-in [MangaDex](https://mangadex.org/) downloader
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
@@ -74,20 +75,23 @@ mangadex:
- `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
### Required Library Structure
### Library Structure
Please make sure that your library directory has the following structure:
You can organize your `.cbz/.zip` files in nested folders in the library directory. Here's an example:
```
.
├── Manga 1
│   └── Manga 1.cbz
│   ├── Volume 1.cbz
│   ├── Volume 2.cbz
│   ├── Volume 3.cbz
│   └── Volume 4.zip
└── Manga 2
├── Vol 0001.zip
├── Vol 0002.zip
├── Vol 0003.zip
├── Vol 0004.zip
└── Vol 0005.zip
   └── Vol. 1
   └── Ch.1 - Ch.3
   ├── 1.zip
   ├── 2.zip
   └── 3.zip
```
### Initial Login

View File

@@ -20,7 +20,7 @@
#scan-status {
cursor: auto;
}
.uk-card-title {
.break-word {
word-wrap: break-word;
}
.uk-logo > img {

View File

@@ -93,8 +93,8 @@ $(() => {
else if (by === 'date')
res = $(a).attr('data-mtime') > $(b).attr('data-mtime');
else if (by === 'progress') {
const ap = $(a).attr('data-progress');
const bp = $(b).attr('data-progress');
const ap = parseFloat($(a).attr('data-progress'));
const bp = parseFloat($(b).attr('data-progress'));
if (ap === bp)
// if progress is the same, we compare by name
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();

View File

@@ -15,7 +15,7 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
if (percentage === 100) {
$('#read-btn').attr('hidden', '');
}
$('#modal-title').text(title);
$('#modal-title').text(entry);
$('#path-text').text(zipPath);
$('#pages-text').text(pages + ' pages');

View File

@@ -1,5 +1,5 @@
name: mango
version: 0.1.0
version: 0.2.3
authors:
- Alex Ling <hkalexling@gmail.com>

View File

@@ -25,4 +25,12 @@ describe "compare_alphanumerically" do
compare_alphanumerically a, b
}.should eq ary
end
# https://github.com/hkalexling/Mango/issues/22
it "handles numbers larger than Int32" do
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
ary.reverse.sort {|a, b|
compare_alphanumerically a, b
}.should eq ary
end
end

View File

@@ -15,10 +15,10 @@ struct Image
end
class Entry
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
property 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, @title_id, storage)
@zip_path = path
@@ -38,6 +38,19 @@ class Entry
@cover_url = "/api/page/#{@title_id}/#{@id}/1"
@mtime = File.info(@zip_path).modification_time
end
def to_json(json : JSON::Builder)
json.object do
{% for str in ["zip_path", "book_title", "title", "size",
"cover_url", "id", "title_id", "encoded_path",
"encoded_title"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "pages" {json.number @pages}
json.field "mtime" {json.number @mtime.to_unix}
end
end
def read_page(page_num)
Zip::File.open @zip_path do |file|
page = file.entries
@@ -63,27 +76,94 @@ class Entry
end
class Title
JSON.mapping dir: String, entries: Array(Entry), title: String,
id: String, encoded_title: String, mtime: Time, logger: MLogger
property dir : String, parent_id : String, title_ids : Array(String),
entries : Array(Entry), title : String, id : String,
encoded_title : String, mtime : Time
def initialize(dir : String, storage, @logger : MLogger)
def initialize(dir : String, @parent_id, storage,
@logger : MLogger, @library : Library)
@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| File.join dir, path }
.select { |path| valid_zip path }
.map { |path|
Entry.new path, @title, @id, storage
}
.select { |e| e.pages > 0 }
.sort { |a, b| a.title <=> b.title }
@title_ids = [] of String
@entries = [] of Entry
Dir.entries(dir).each do |fn|
next if fn.starts_with? "."
path = File.join dir, fn
if File.directory? path
title = Title.new path, @id, storage, @logger, library
next if title.entries.size == 0 && title.titles.size == 0
@library.title_hash[title.id] = title
@title_ids << title.id
next
end
if [".zip", ".cbz"].includes? File.extname path
next if !valid_zip path
entry = Entry.new path, @title, @id, storage
@entries << entry if entry.pages > 0
end
end
@title_ids.sort! do |a, b|
compare_alphanumerically @library.title_hash[a].title,
@library.title_hash[b].title
end
@entries.sort! do |a, b|
compare_alphanumerically a.title, b.title
end
mtimes = [File.info(dir).modification_time]
mtimes += @title_ids.map{|e| @library.title_hash[e].mtime}
mtimes += @entries.map{|e| e.mtime}
@mtime = mtimes.max
end
def to_json(json : JSON::Builder)
json.object do
{% for str in ["dir", "title", "id", "encoded_title"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "mtime" {json.number @mtime.to_unix}
json.field "titles" do
json.raw self.titles.to_json
end
json.field "entries" do
json.raw @entries.to_json
end
json.field "parents" do
json.array do
self.parents.each do |title|
json.object do
json.field "title", title.title
json.field "id", title.id
end
end
end
end
end
end
def titles
@title_ids.map {|tid| @library.get_title! tid}
end
def parents
ary = [] of Title
tid = @parent_id
while !tid.empty?
title = @library.get_title! tid
ary << title
tid = title.parent_id
end
ary
end
def size
@entries.size + @title_ids.size
end
# When downloading from MangaDex, the zip/cbz file would not be valid
# before the download is completed. If we scan the zip file,
# Entry.new would throw, so we use this method to check before
@@ -132,6 +212,7 @@ class Title
page / entry_obj.pages
end
def load_percetage(username)
return 0 if @entries.empty?
read_pages = total_pages = 0
@entries.each do |e|
read_pages += load_progress username, e.title
@@ -150,10 +231,7 @@ class TitleInfo
# { user1: { entry1: 10, entry2: 0 } }
include JSON::Serializable
@[JSON::Field(key: "comment")]
property comment = "Generated by Mango. DO NOT EDIT!"
@[JSON::Field(key: "progress")]
property progress : Hash(String, Hash(String, Int32))
def initialize(title_dir)
@@ -175,13 +253,14 @@ class TitleInfo
end
class Library
JSON.mapping dir: String, titles: Array(Title), scan_interval: Int32,
logger: MLogger, storage: Storage
property dir : String, title_ids : Array(String), scan_interval : Int32,
logger : MLogger, storage : Storage, title_hash : Hash(String, Title)
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
@title_ids = [] of String
@title_hash = {} of String => Title
return scan if @scan_interval < 1
spawn do
@@ -189,13 +268,27 @@ class Library
start = Time.local
scan
ms = (Time.local - start).total_milliseconds
@logger.info "Scanned #{@titles.size} titles in #{ms}ms"
@logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
sleep @scan_interval * 60
end
end
end
def titles
@title_ids.map {|tid| self.get_title!(tid) }
end
def to_json(json : JSON::Builder)
json.object do
json.field "dir", @dir
json.field "titles" do
json.raw self.titles.to_json
end
end
end
def get_title(tid)
@titles.find { |t| t.id == tid }
@title_hash[tid]?
end
def get_title!(tid)
@title_hash[tid]
end
def scan
unless Dir.exists? @dir
@@ -203,11 +296,18 @@ class Library
"Attempting to create it"
Dir.mkdir_p @dir
end
@titles = (Dir.entries @dir)
.select { |path| File.directory? File.join @dir, path }
.map { |path| Title.new File.join(@dir, path), @storage, @logger }
.select { |title| !title.entries.empty? }
@title_ids.clear
(Dir.entries @dir)
.select { |fn| !fn.starts_with? "." }
.map { |fn| File.join @dir, fn }
.select { |path| File.directory? path }
.map { |path| Title.new path, "", @storage, @logger, self }
.select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort { |a, b| a.title <=> b.title }
.each do |title|
@title_hash[title.id] = title
@title_ids << title.id
end
@logger.debug "Scan completed"
end
end

View File

@@ -3,7 +3,7 @@ require "./context"
require "./mangadex/*"
require "option_parser"
VERSION = "0.2.0"
VERSION = "0.2.3"
config_path = nil

View File

@@ -26,7 +26,7 @@ class APIRouter < Router
end
end
get "/api/book/:title" do |env|
get "/api/book/:tid" do |env|
begin
tid = env.params.url["tid"]
title = @context.library.get_title tid

View File

@@ -26,6 +26,7 @@ class MainRouter < Router
.not_nil!
cookie = HTTP::Cookie.new "token", token
cookie.expires = Time.local.shift years: 1
env.response.cookies << cookie
env.redirect "/"
rescue
@@ -46,6 +47,8 @@ class MainRouter < Router
username = get_username env
percentage = title.entries.map { |e|
title.load_percetage username, e.title }
titles_percentage = title.titles.map { |t|
title.load_percetage username, t.title }
layout "title"
rescue e
@context.error e

View File

@@ -1,3 +1,5 @@
require "big"
IMGS_PER_PAGE = 5
macro layout(name)
@@ -56,7 +58,7 @@ def compare_alphanumerically(c, d)
return -1 if a.nil?
return 1 if b.nil?
if is_numeric(a) && is_numeric(b)
compare = a.to_i <=> b.to_i
compare = a.to_big_i <=> b.to_big_i
return compare if compare != 0
else
compare = a <=> b

View File

@@ -26,12 +26,18 @@
<a class="acard" href="/book/<%= t.id %>">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top">
<%- if t.entries.size > 0 -%>
<img src="<%= t.entries[0].cover_url %>" alt="">
<%- else -%>
<img src="/img/icon.png" alt="">
<%- end -%>
</div>
<div class="uk-card-body">
<%- if t.entries.size > 0 -%>
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
<h3 class="uk-card-title"><%= t.title %></h3>
<p><%= t.entries.size %> entries</p>
<%- end -%>
<h3 class="uk-card-title break-word"><%= t.title %></h3>
<p><%= t.size %> entries</p>
</div>
</div>
</a>

View File

@@ -1,5 +1,12 @@
<h2 class=uk-title><%= title.title %></h2>
<p class="uk-text-meta"><%= title.entries.size %> entries found</p>
<ul class="uk-breadcrumb">
<li><a href="/">Library</a></li>
<%- title.parents.each do |t| -%>
<li><a href="/book/<%= t.id %>"><%= t.title %></a></li>
<%- end -%>
<li class="uk-disabled"><a><%= title.title %></a></li>
</ul>
<p class="uk-text-meta"><%= title.size %> entries found</p>
<div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default">
@@ -23,6 +30,28 @@
</div>
</div>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- title.titles.each_with_index do |t, i| -%>
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="<%= titles_percentage[i] %>">
<a class="acard" href="/book/<%= t.id %>">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top">
<%- if t.entries.size > 0 -%>
<img src="<%= t.entries[0].cover_url %>" alt="">
<%- else -%>
<img src="/img/icon.png" alt="">
<%- end -%>
</div>
<div class="uk-card-body">
<%- if t.entries.size > 0 -%>
<div class="uk-card-badge uk-label"><%= (titles_percentage[i] * 100).round(1) %>%</div>
<%- end -%>
<h3 class="uk-card-title break-word"><%= t.title %></h3>
<p><%= t.size %> entries</p>
</div>
</div>
</a>
</div>
<%- end -%>
<%- title.entries.each_with_index do |e, i| -%>
<div class="item" data-mtime="<%= e.mtime.to_unix %>" data-progress="<%= percentage[i] %>">
<a class="acard">
@@ -44,8 +73,8 @@
<div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<h3 class="uk-modal-title" id="modal-title"></h3>
<p class="uk-text-meta uk-margin-remove-bottom" id="path-text"></p>
<h3 class="uk-modal-title break-word" id="modal-title"></h3>
<p class="uk-text-meta uk-margin-remove-bottom break-word" id="path-text"></p>
<p class="uk-text-meta uk-margin-remove-top" id="pages-text"></p>
</div>
<div class="uk-modal-body">