Skip to content
This repository was archived by the owner on Mar 23, 2025. It is now read-only.

Commit bf68e32

Browse files
committed
Merge branch 'dev'
2 parents 4d709b7 + 54eb041 commit bf68e32

File tree

17 files changed

+348
-82
lines changed

17 files changed

+348
-82
lines changed

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Mango is a self-hosted manga server and reader. Its features include
1212
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar`
1313
- Supports nested folders in library
1414
- Automatically stores reading progress
15+
- Thumbnail generation
1516
- Built-in [MangaDex](https://mangadex.org/) downloader
1617
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites
1718
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
@@ -51,7 +52,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
5152
### CLI
5253

5354
```
54-
Mango - Manga Server and Web Reader. Version 0.14.0
55+
Mango - Manga Server and Web Reader. Version 0.15.0
5556
5657
Usage:
5758
@@ -80,6 +81,8 @@ session_secret: mango-session-secret
8081
library_path: ~/mango/library
8182
db_path: ~/mango/mango.db
8283
scan_interval_minutes: 5
84+
thumbnail_generation_interval_hours: 24
85+
db_optimization_interval_hours: 24
8386
log_level: info
8487
upload_path: ~/mango/uploads
8588
plugin_path: ~/mango/plugins
@@ -89,12 +92,12 @@ mangadex:
8992
api_url: https://mangadex.org/api
9093
download_wait_seconds: 5
9194
download_retries: 4
92-
download_queue_db_path: ~/mango/queue.db
95+
download_queue_db_path: /home/alex_ling/mango/queue.db
9396
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
9497
manga_rename_rule: '{title}'
9598
```
9699
97-
- `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan
100+
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
98101
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
99102

100103
### Library Structure

public/js/admin.js

Lines changed: 82 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,90 @@
1-
let scanning = false;
2-
3-
const scan = () => {
4-
scanning = true;
5-
$('#scan-status > div').removeAttr('hidden');
6-
$('#scan-status > span').attr('hidden', '');
7-
const color = $('#scan').css('color');
8-
$('#scan').css('color', 'gray');
9-
$.post(base_url + 'api/admin/scan', (data) => {
10-
const ms = data.milliseconds;
11-
const titles = data.titles;
12-
$('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms');
13-
$('#scan-status > span').removeAttr('hidden');
14-
$('#scan').css('color', color);
15-
$('#scan-status > div').attr('hidden', '');
16-
scanning = false;
17-
});
18-
}
19-
20-
String.prototype.capitalize = function() {
21-
return this.charAt(0).toUpperCase() + this.slice(1);
22-
}
23-
241
$(() => {
25-
$('li').click((e) => {
26-
const url = $(e.currentTarget).attr('data-url');
27-
if (url) {
28-
$(location).attr('href', url);
29-
}
30-
});
31-
322
const setting = loadThemeSetting();
33-
$('#theme-select').val(setting.capitalize());
34-
3+
$('#theme-select').val(capitalize(setting));
354
$('#theme-select').change((e) => {
365
const newSetting = $(e.currentTarget).val().toLowerCase();
376
saveThemeSetting(newSetting);
387
setTheme();
398
});
9+
10+
getProgress();
11+
setInterval(getProgress, 5000);
4012
});
13+
14+
/**
15+
* Capitalize String
16+
*
17+
* @function capitalize
18+
* @param {string} str - The string to be capitalized
19+
* @return {string} The capitalized string
20+
*/
21+
const capitalize = (str) => {
22+
return str.charAt(0).toUpperCase() + str.slice(1);
23+
};
24+
25+
/**
26+
* Set an alpine.js property
27+
*
28+
* @function setProp
29+
* @param {string} key - Key of the data property
30+
* @param {*} prop - The data property
31+
*/
32+
const setProp = (key, prop) => {
33+
$('#root').get(0).__x.$data[key] = prop;
34+
};
35+
36+
/**
37+
* Get an alpine.js property
38+
*
39+
* @function getProp
40+
* @param {string} key - Key of the data property
41+
* @return {*} The data property
42+
*/
43+
const getProp = (key) => {
44+
return $('#root').get(0).__x.$data[key];
45+
};
46+
47+
/**
48+
* Get the thumbnail generation progress from the API
49+
*
50+
* @function getProgress
51+
*/
52+
const getProgress = () => {
53+
$.get(`${base_url}api/admin/thumbnail_progress`)
54+
.then(data => {
55+
setProp('progress', data.progress);
56+
const generating = data.progress > 0
57+
setProp('generating', generating);
58+
});
59+
};
60+
61+
/**
62+
* Trigger the thumbnail generation
63+
*
64+
* @function generateThumbnails
65+
*/
66+
const generateThumbnails = () => {
67+
setProp('generating', true);
68+
setProp('progress', 0.0);
69+
$.post(`${base_url}api/admin/generate_thumbnails`)
70+
.then(getProgress);
71+
};
72+
73+
/**
74+
* Trigger the scan
75+
*
76+
* @function scan
77+
*/
78+
const scan = () => {
79+
setProp('scanning', true);
80+
setProp('scanMs', -1);
81+
setProp('scanTitles', 0);
82+
$.post(`${base_url}api/admin/scan`)
83+
.then(data => {
84+
setProp('scanMs', data.milliseconds);
85+
setProp('scanTitles', data.titles);
86+
})
87+
.always(() => {
88+
setProp('scanning', false);
89+
});
90+
}

public/js/dots.js

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
1-
const truncate = () => {
2-
$('.uk-card-title').each((i, e) => {
3-
$(e).dotdotdot({
4-
truncate: 'letter',
5-
watch: true,
6-
callback: (truncated) => {
7-
if (truncated) {
8-
$(e).attr('uk-tooltip', $(e).attr('data-title'));
9-
} else {
10-
$(e).removeAttr('uk-tooltip');
11-
}
1+
/**
2+
* Truncate a .uk-card-title element
3+
*
4+
* @function truncate
5+
* @param {object} e - The title element to truncate
6+
*/
7+
const truncate = (e) => {
8+
$(e).dotdotdot({
9+
truncate: 'letter',
10+
watch: true,
11+
callback: (truncated) => {
12+
if (truncated) {
13+
$(e).attr('uk-tooltip', $(e).attr('data-title'));
14+
} else {
15+
$(e).removeAttr('uk-tooltip');
1216
}
13-
});
17+
}
1418
});
1519
};
1620

17-
truncate();
21+
$('.uk-card-title').each((i, e) => {
22+
// Truncate the title when it first enters the view
23+
$(e).one('inview', () => {
24+
truncate(e);
25+
});
26+
});

shard.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ shards:
3434

3535
image_size:
3636
github: hkalexling/image_size.cr
37-
version: 0.2.0
37+
version: 0.4.0
3838

3939
kemal:
4040
github: kemalcr/kemal

shard.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: mango
2-
version: 0.14.0
2+
version: 0.15.0
33

44
authors:
55
- Alex Ling <hkalexling@gmail.com>

src/config.cr

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ class Config
1111
property library_path : String = File.expand_path "~/mango/library",
1212
home: true
1313
property db_path : String = File.expand_path "~/mango/mango.db", home: true
14-
@[YAML::Field(key: "scan_interval_minutes")]
15-
property scan_interval : Int32 = 5
14+
property scan_interval_minutes : Int32 = 5
15+
property thumbnail_generation_interval_hours : Int32 = 24
16+
property db_optimization_interval_hours : Int32 = 24
1617
property log_level : String = "info"
1718
property upload_path : String = File.expand_path "~/mango/uploads",
1819
home: true

src/library/entry.cr

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ class Entry
6969

7070
def cover_url
7171
return "#{Config.current.base_url}img/icon.png" if @err_msg
72-
url = "#{Config.current.base_url}api/page/#{@book.id}/#{@id}/1"
72+
url = "#{Config.current.base_url}api/cover/#{@book.id}/#{@id}"
7373
TitleInfo.new @book.dir do |info|
7474
info_url = info.entry_cover_url[@title]?
7575
unless info_url.nil? || info_url.empty?
@@ -207,4 +207,29 @@ class Entry
207207
def started?(username)
208208
load_progress(username) > 0
209209
end
210+
211+
def generate_thumbnail : Image?
212+
return if @err_msg
213+
214+
img = read_page(1).not_nil!
215+
begin
216+
size = ImageSize.get img.data
217+
if size.height > size.width
218+
thumbnail = ImageSize.resize img.data, width: 200
219+
else
220+
thumbnail = ImageSize.resize img.data, height: 300
221+
end
222+
img.data = thumbnail
223+
Storage.default.save_thumbnail @id, img
224+
rescue e
225+
Logger.warn "Failed to generate thumbnail for entry " \
226+
"#{@book.title}/#{@title}. #{e}"
227+
end
228+
229+
img
230+
end
231+
232+
def get_thumbnail : Image?
233+
Storage.default.get_thumbnail @id
234+
end
210235
end

src/library/library.cr

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
class Library
2-
property dir : String, title_ids : Array(String), scan_interval : Int32,
2+
property dir : String, title_ids : Array(String),
33
title_hash : Hash(String, Title)
44

55
use_default
@@ -8,20 +8,48 @@ class Library
88
register_mime_types
99

1010
@dir = Config.current.library_path
11-
@scan_interval = Config.current.scan_interval
1211
# explicitly initialize @titles to bypass the compiler check. it will
1312
# be filled with actual Titles in the `scan` call below
1413
@title_ids = [] of String
1514
@title_hash = {} of String => Title
1615

17-
return scan if @scan_interval < 1
18-
spawn do
19-
loop do
20-
start = Time.local
21-
scan
22-
ms = (Time.local - start).total_milliseconds
23-
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
24-
sleep @scan_interval * 60
16+
@entries_count = 0
17+
@thumbnails_count = 0
18+
19+
scan_interval = Config.current.scan_interval_minutes
20+
if scan_interval < 1
21+
scan
22+
else
23+
spawn do
24+
loop do
25+
start = Time.local
26+
scan
27+
ms = (Time.local - start).total_milliseconds
28+
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
29+
sleep scan_interval.minutes
30+
end
31+
end
32+
end
33+
34+
thumbnail_interval = Config.current.thumbnail_generation_interval_hours
35+
unless thumbnail_interval < 1
36+
spawn do
37+
loop do
38+
# Wait for scan to complete (in most cases)
39+
sleep 1.minutes
40+
generate_thumbnails
41+
sleep thumbnail_interval.hours
42+
end
43+
end
44+
end
45+
46+
db_interval = Config.current.db_optimization_interval_hours
47+
unless db_interval < 1
48+
spawn do
49+
loop do
50+
Storage.default.optimize
51+
sleep db_interval.hours
52+
end
2553
end
2654
end
2755
end
@@ -194,4 +222,50 @@ class Library
194222
.sample(ENTRIES_IN_HOME_SECTIONS)
195223
.shuffle
196224
end
225+
226+
def thumbnail_generation_progress
227+
return 0 if @entries_count == 0
228+
@thumbnails_count / @entries_count
229+
end
230+
231+
def generate_thumbnails
232+
if @thumbnails_count > 0
233+
Logger.debug "Thumbnail generation in progress"
234+
return
235+
end
236+
237+
Logger.info "Starting thumbnail generation"
238+
entries = deep_titles.map(&.deep_entries).flatten.reject &.err_msg
239+
@entries_count = entries.size
240+
@thumbnails_count = 0
241+
242+
# Report generation progress regularly
243+
spawn do
244+
loop do
245+
unless @thumbnails_count == 0
246+
Logger.debug "Thumbnail generation progress: " \
247+
"#{(thumbnail_generation_progress * 100).round 1}%"
248+
end
249+
# Generation is completed. We reset the count to 0 to allow subsequent
250+
# calls to the function, and break from the loop to stop the progress
251+
# report fiber
252+
if thumbnail_generation_progress.to_i == 1
253+
@thumbnails_count = 0
254+
break
255+
end
256+
sleep 10.seconds
257+
end
258+
end
259+
260+
entries.each do |e|
261+
unless e.get_thumbnail
262+
e.generate_thumbnail
263+
# Sleep after each generation to minimize the impact on disk IO
264+
# and CPU
265+
sleep 0.5.seconds
266+
end
267+
@thumbnails_count += 1
268+
end
269+
Logger.info "Thumbnail generation finished"
270+
end
197271
end

0 commit comments

Comments
 (0)