mirror of
https://github.com/hkalexling/Mango.git
synced 2025-08-03 03:15:31 -04:00
Break library.cr
into multiple files
This commit is contained in:
parent
ff4b1be9ae
commit
6d97bc083c
828
src/library.cr
828
src/library.cr
@ -1,828 +0,0 @@
|
||||
require "mime"
|
||||
require "json"
|
||||
require "uri"
|
||||
require "./util/*"
|
||||
require "./archive"
|
||||
|
||||
SUPPORTED_IMG_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
||||
|
||||
enum SortMethod
|
||||
Auto
|
||||
Title
|
||||
Progress
|
||||
TimeModified
|
||||
TimeAdded
|
||||
end
|
||||
|
||||
class SortOptions
|
||||
property method : SortMethod, ascend : Bool
|
||||
|
||||
def initialize(in_method : String? = nil, @ascend = true)
|
||||
@method = SortMethod::Auto
|
||||
SortMethod.each do |m, _|
|
||||
if in_method && m.to_s.underscore == in_method
|
||||
@method = m
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(in_method : SortMethod? = nil, @ascend = true)
|
||||
if in_method
|
||||
@method = in_method
|
||||
else
|
||||
@method = SortMethod::Auto
|
||||
end
|
||||
end
|
||||
|
||||
def self.from_tuple(tp : Tuple(String, Bool))
|
||||
method, ascend = tp
|
||||
self.new method, ascend
|
||||
end
|
||||
|
||||
def self.from_info_json(dir, username)
|
||||
opt = SortOptions.new
|
||||
TitleInfo.new dir do |info|
|
||||
if info.sort_by.has_key? username
|
||||
opt = SortOptions.from_tuple info.sort_by[username]
|
||||
end
|
||||
end
|
||||
opt
|
||||
end
|
||||
|
||||
def to_tuple
|
||||
{@method.to_s.underscore, ascend}
|
||||
end
|
||||
end
|
||||
|
||||
struct Image
|
||||
property data : Bytes
|
||||
property mime : String
|
||||
property filename : String
|
||||
property size : Int32
|
||||
|
||||
def initialize(@data, @mime, @filename, @size)
|
||||
end
|
||||
end
|
||||
|
||||
class Entry
|
||||
property zip_path : String, book : Title, title : String,
|
||||
size : String, pages : Int32, id : String, title_id : String,
|
||||
encoded_path : String, encoded_title : String, mtime : Time
|
||||
|
||||
def initialize(path, @book, @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 = ArchiveFile.new path
|
||||
@pages = file.entries.count do |e|
|
||||
SUPPORTED_IMG_TYPES.includes? \
|
||||
MIME.from_filename? e.filename
|
||||
end
|
||||
file.close
|
||||
id = storage.get_id @zip_path, false
|
||||
if id.nil?
|
||||
id = random_str
|
||||
storage.insert_id({
|
||||
path: @zip_path,
|
||||
id: id,
|
||||
is_title: false,
|
||||
})
|
||||
end
|
||||
@id = id
|
||||
@mtime = File.info(@zip_path).modification_time
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.object do
|
||||
{% for str in ["zip_path", "title", "size", "id", "title_id",
|
||||
"encoded_path", "encoded_title"] %}
|
||||
json.field {{str}}, @{{str.id}}
|
||||
{% end %}
|
||||
json.field "display_name", @book.display_name @title
|
||||
json.field "cover_url", cover_url
|
||||
json.field "pages" { json.number @pages }
|
||||
json.field "mtime" { json.number @mtime.to_unix }
|
||||
end
|
||||
end
|
||||
|
||||
def display_name
|
||||
@book.display_name @title
|
||||
end
|
||||
|
||||
def encoded_display_name
|
||||
URI.encode display_name
|
||||
end
|
||||
|
||||
def cover_url
|
||||
url = "#{Config.current.base_url}api/page/#{@title_id}/#{@id}/1"
|
||||
TitleInfo.new @book.dir do |info|
|
||||
info_url = info.entry_cover_url[@title]?
|
||||
unless info_url.nil? || info_url.empty?
|
||||
url = File.join Config.current.base_url, info_url
|
||||
end
|
||||
end
|
||||
url
|
||||
end
|
||||
|
||||
def read_page(page_num)
|
||||
img = nil
|
||||
ArchiveFile.open @zip_path do |file|
|
||||
page = file.entries
|
||||
.select { |e|
|
||||
SUPPORTED_IMG_TYPES.includes? \
|
||||
MIME.from_filename? e.filename
|
||||
}
|
||||
.sort { |a, b|
|
||||
compare_numerically a.filename, b.filename
|
||||
}
|
||||
.[page_num - 1]
|
||||
data = file.read_entry page
|
||||
if data
|
||||
img = Image.new data, MIME.from_filename(page.filename), page.filename,
|
||||
data.size
|
||||
end
|
||||
end
|
||||
img
|
||||
end
|
||||
|
||||
def next_entry(username)
|
||||
entries = @book.sorted_entries username
|
||||
idx = entries.index self
|
||||
return nil if idx.nil? || idx == entries.size - 1
|
||||
entries[idx + 1]
|
||||
end
|
||||
|
||||
def previous_entry
|
||||
idx = @book.entries.index self
|
||||
return nil if idx.nil? || idx == 0
|
||||
@book.entries[idx - 1]
|
||||
end
|
||||
|
||||
def date_added
|
||||
date_added = nil
|
||||
TitleInfo.new @book.dir do |info|
|
||||
info_da = info.date_added[@title]?
|
||||
if info_da.nil?
|
||||
date_added = info.date_added[@title] = ctime @zip_path
|
||||
info.save
|
||||
else
|
||||
date_added = info_da
|
||||
end
|
||||
end
|
||||
date_added.not_nil! # is it ok to set not_nil! here?
|
||||
end
|
||||
|
||||
# For backward backward compatibility with v0.1.0, we save entry titles
|
||||
# instead of IDs in info.json
|
||||
def save_progress(username, page)
|
||||
TitleInfo.new @book.dir do |info|
|
||||
if info.progress[username]?.nil?
|
||||
info.progress[username] = {@title => page}
|
||||
else
|
||||
info.progress[username][@title] = page
|
||||
end
|
||||
# save last_read timestamp
|
||||
if info.last_read[username]?.nil?
|
||||
info.last_read[username] = {@title => Time.utc}
|
||||
else
|
||||
info.last_read[username][@title] = Time.utc
|
||||
end
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
def load_progress(username)
|
||||
progress = 0
|
||||
TitleInfo.new @book.dir do |info|
|
||||
unless info.progress[username]?.nil? ||
|
||||
info.progress[username][@title]?.nil?
|
||||
progress = info.progress[username][@title]
|
||||
end
|
||||
end
|
||||
[progress, @pages].min
|
||||
end
|
||||
|
||||
def load_percentage(username)
|
||||
page = load_progress username
|
||||
page / @pages
|
||||
end
|
||||
|
||||
def load_last_read(username)
|
||||
last_read = nil
|
||||
TitleInfo.new @book.dir do |info|
|
||||
unless info.last_read[username]?.nil? ||
|
||||
info.last_read[username][@title]?.nil?
|
||||
last_read = info.last_read[username][@title]
|
||||
end
|
||||
end
|
||||
last_read
|
||||
end
|
||||
|
||||
def finished?(username)
|
||||
load_progress(username) == @pages
|
||||
end
|
||||
|
||||
def started?(username)
|
||||
load_progress(username) > 0
|
||||
end
|
||||
end
|
||||
|
||||
class Title
|
||||
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, @parent_id, storage,
|
||||
@library : Library)
|
||||
id = storage.get_id @dir, true
|
||||
if id.nil?
|
||||
id = random_str
|
||||
storage.insert_id({
|
||||
path: @dir,
|
||||
id: id,
|
||||
is_title: true,
|
||||
})
|
||||
end
|
||||
@id = id
|
||||
@title = File.basename dir
|
||||
@encoded_title = URI.encode @title
|
||||
@title_ids = [] of String
|
||||
@entries = [] of Entry
|
||||
@mtime = File.info(dir).modification_time
|
||||
|
||||
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, 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", ".rar", ".cbr"].includes? File.extname path
|
||||
unless File.readable? path
|
||||
Logger.warn "File #{path} is not readable. Please make sure the " \
|
||||
"file permission is configured correctly."
|
||||
next
|
||||
end
|
||||
archive_exception = validate_archive path
|
||||
unless archive_exception.nil?
|
||||
Logger.warn "Unable to extract archive #{path}. Ignoring it. " \
|
||||
"Archive error: #{archive_exception}"
|
||||
next
|
||||
end
|
||||
entry = Entry.new path, self, @id, storage
|
||||
@entries << entry if entry.pages > 0
|
||||
end
|
||||
end
|
||||
|
||||
mtimes = [@mtime]
|
||||
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime }
|
||||
mtimes += @entries.map { |e| e.mtime }
|
||||
@mtime = mtimes.max
|
||||
|
||||
@title_ids.sort! do |a, b|
|
||||
compare_numerically @library.title_hash[a].title,
|
||||
@library.title_hash[b].title
|
||||
end
|
||||
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
||||
@entries.sort! do |a, b|
|
||||
sorter.compare a.title, b.title
|
||||
end
|
||||
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 "display_name", display_name
|
||||
json.field "cover_url", cover_url
|
||||
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
|
||||
|
||||
# Get all entries, including entries in nested titles
|
||||
def deep_entries
|
||||
return @entries if title_ids.empty?
|
||||
@entries + titles.map { |t| t.deep_entries }.flatten
|
||||
end
|
||||
|
||||
def deep_titles
|
||||
return [] of Title if titles.empty?
|
||||
titles + titles.map { |t| t.deep_titles }.flatten
|
||||
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.reverse
|
||||
end
|
||||
|
||||
def size
|
||||
@entries.size + @title_ids.size
|
||||
end
|
||||
|
||||
def get_entry(eid)
|
||||
@entries.find { |e| e.id == eid }
|
||||
end
|
||||
|
||||
def display_name
|
||||
dn = @title
|
||||
TitleInfo.new @dir do |info|
|
||||
info_dn = info.display_name
|
||||
dn = info_dn unless info_dn.empty?
|
||||
end
|
||||
dn
|
||||
end
|
||||
|
||||
def encoded_display_name
|
||||
URI.encode display_name
|
||||
end
|
||||
|
||||
def display_name(entry_name)
|
||||
dn = entry_name
|
||||
TitleInfo.new @dir do |info|
|
||||
info_dn = info.entry_display_name[entry_name]?
|
||||
unless info_dn.nil? || info_dn.empty?
|
||||
dn = info_dn
|
||||
end
|
||||
end
|
||||
dn
|
||||
end
|
||||
|
||||
def set_display_name(dn)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.display_name = dn
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
def set_display_name(entry_name : String, dn)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.entry_display_name[entry_name] = dn
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
def cover_url
|
||||
url = "#{Config.current.base_url}img/icon.png"
|
||||
if @entries.size > 0
|
||||
url = @entries[0].cover_url
|
||||
end
|
||||
TitleInfo.new @dir do |info|
|
||||
info_url = info.cover_url
|
||||
unless info_url.nil? || info_url.empty?
|
||||
url = File.join Config.current.base_url, info_url
|
||||
end
|
||||
end
|
||||
url
|
||||
end
|
||||
|
||||
def set_cover_url(url : String)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.cover_url = url
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
def set_cover_url(entry_name : String, url : String)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.entry_cover_url[entry_name] = url
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
# Set the reading progress of all entries and nested libraries to 100%
|
||||
def read_all(username)
|
||||
@entries.each do |e|
|
||||
e.save_progress username, e.pages
|
||||
end
|
||||
titles.each do |t|
|
||||
t.read_all username
|
||||
end
|
||||
end
|
||||
|
||||
# Set the reading progress of all entries and nested libraries to 0%
|
||||
def unread_all(username)
|
||||
@entries.each do |e|
|
||||
e.save_progress username, 0
|
||||
end
|
||||
titles.each do |t|
|
||||
t.unread_all username
|
||||
end
|
||||
end
|
||||
|
||||
def deep_read_page_count(username) : Int32
|
||||
load_progress_for_all_entries(username).sum +
|
||||
titles.map { |t| t.deep_read_page_count username }.flatten.sum
|
||||
end
|
||||
|
||||
def deep_total_page_count : Int32
|
||||
entries.map { |e| e.pages }.sum +
|
||||
titles.map { |t| t.deep_total_page_count }.flatten.sum
|
||||
end
|
||||
|
||||
def load_percentage(username)
|
||||
deep_read_page_count(username) / deep_total_page_count
|
||||
end
|
||||
|
||||
def load_progress_for_all_entries(username, opt : SortOptions? = nil,
|
||||
unsorted = false)
|
||||
progress = {} of String => Int32
|
||||
TitleInfo.new @dir do |info|
|
||||
progress = info.progress[username]?
|
||||
end
|
||||
|
||||
if unsorted
|
||||
ary = @entries
|
||||
else
|
||||
ary = sorted_entries username, opt
|
||||
end
|
||||
|
||||
ary.map do |e|
|
||||
info_progress = 0
|
||||
if progress && progress.has_key? e.title
|
||||
info_progress = [progress[e.title], e.pages].min
|
||||
end
|
||||
info_progress
|
||||
end
|
||||
end
|
||||
|
||||
def load_percentage_for_all_entries(username, opt : SortOptions? = nil,
|
||||
unsorted = false)
|
||||
if unsorted
|
||||
ary = @entries
|
||||
else
|
||||
ary = sorted_entries username, opt
|
||||
end
|
||||
|
||||
progress = load_progress_for_all_entries username, opt, unsorted
|
||||
ary.map_with_index do |e, i|
|
||||
progress[i] / e.pages
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the sorted entries array
|
||||
#
|
||||
# When `opt` is nil, it uses the preferred sorting options in info.json, or
|
||||
# use the default (auto, ascending)
|
||||
# When `opt` is not nil, it saves the options to info.json
|
||||
def sorted_entries(username, opt : SortOptions? = nil)
|
||||
if opt.nil?
|
||||
opt = SortOptions.from_info_json @dir, username
|
||||
else
|
||||
TitleInfo.new @dir do |info|
|
||||
info.sort_by[username] = opt.to_tuple
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
case opt.not_nil!.method
|
||||
when .title?
|
||||
ary = @entries.sort { |a, b| compare_numerically a.title, b.title }
|
||||
when .time_modified?
|
||||
ary = @entries.sort { |a, b| (a.mtime <=> b.mtime).or \
|
||||
compare_numerically a.title, b.title }
|
||||
when .time_added?
|
||||
ary = @entries.sort { |a, b| (a.date_added <=> b.date_added).or \
|
||||
compare_numerically a.title, b.title }
|
||||
when .progress?
|
||||
percentage_ary = load_percentage_for_all_entries username, opt, true
|
||||
ary = @entries.zip(percentage_ary)
|
||||
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
|
||||
compare_numerically a_tp[0].title, b_tp[0].title }
|
||||
.map { |tp| tp[0] }
|
||||
else
|
||||
unless opt.method.auto?
|
||||
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||
"Auto instead"
|
||||
end
|
||||
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
||||
ary = @entries.sort do |a, b|
|
||||
sorter.compare a.title, b.title
|
||||
end
|
||||
end
|
||||
|
||||
ary.reverse! unless opt.not_nil!.ascend
|
||||
|
||||
ary
|
||||
end
|
||||
|
||||
# === helper methods ===
|
||||
|
||||
# Gets the last read entry in the title. If the entry has been completed,
|
||||
# returns the next entry. Returns nil when no entry has been read yet,
|
||||
# or when all entries are completed
|
||||
def get_last_read_entry(username) : Entry?
|
||||
progress = {} of String => Int32
|
||||
TitleInfo.new @dir do |info|
|
||||
progress = info.progress[username]?
|
||||
end
|
||||
return if progress.nil?
|
||||
|
||||
last_read_entry = nil
|
||||
|
||||
@entries.reverse_each do |e|
|
||||
if progress.has_key?(e.title) && progress[e.title] > 0
|
||||
last_read_entry = e
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if last_read_entry && last_read_entry.finished? username
|
||||
last_read_entry = last_read_entry.next_entry username
|
||||
end
|
||||
|
||||
last_read_entry
|
||||
end
|
||||
|
||||
# Equivalent to `@entries.map &. date_added`, but much more efficient
|
||||
def get_date_added_for_all_entries
|
||||
da = {} of String => Time
|
||||
TitleInfo.new @dir do |info|
|
||||
da = info.date_added
|
||||
end
|
||||
|
||||
@entries.each do |e|
|
||||
next if da.has_key? e.title
|
||||
da[e.title] = ctime e.zip_path
|
||||
end
|
||||
|
||||
TitleInfo.new @dir do |info|
|
||||
info.date_added = da
|
||||
info.save
|
||||
end
|
||||
|
||||
@entries.map { |e| da[e.title] }
|
||||
end
|
||||
|
||||
def deep_entries_with_date_added
|
||||
da_ary = get_date_added_for_all_entries
|
||||
zip = @entries.map_with_index do |e, i|
|
||||
{entry: e, date_added: da_ary[i]}
|
||||
end
|
||||
return zip if title_ids.empty?
|
||||
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten
|
||||
end
|
||||
end
|
||||
|
||||
class TitleInfo
|
||||
include JSON::Serializable
|
||||
|
||||
property comment = "Generated by Mango. DO NOT EDIT!"
|
||||
property progress = {} of String => Hash(String, Int32)
|
||||
property display_name = ""
|
||||
property entry_display_name = {} of String => String
|
||||
property cover_url = ""
|
||||
property entry_cover_url = {} of String => String
|
||||
property last_read = {} of String => Hash(String, Time)
|
||||
property date_added = {} of String => Time
|
||||
property sort_by = {} of String => Tuple(String, Bool)
|
||||
|
||||
@[JSON::Field(ignore: true)]
|
||||
property dir : String = ""
|
||||
|
||||
@@mutex_hash = {} of String => Mutex
|
||||
|
||||
def self.new(dir, &)
|
||||
if @@mutex_hash[dir]?
|
||||
mutex = @@mutex_hash[dir]
|
||||
else
|
||||
mutex = Mutex.new
|
||||
@@mutex_hash[dir] = mutex
|
||||
end
|
||||
mutex.synchronize do
|
||||
instance = TitleInfo.allocate
|
||||
json_path = File.join dir, "info.json"
|
||||
if File.exists? json_path
|
||||
instance = TitleInfo.from_json File.read json_path
|
||||
end
|
||||
instance.dir = dir
|
||||
yield instance
|
||||
end
|
||||
end
|
||||
|
||||
def save
|
||||
json_path = File.join @dir, "info.json"
|
||||
File.write json_path, self.to_pretty_json
|
||||
end
|
||||
end
|
||||
|
||||
class Library
|
||||
property dir : String, title_ids : Array(String), scan_interval : Int32,
|
||||
title_hash : Hash(String, Title)
|
||||
|
||||
def self.default : self
|
||||
unless @@default
|
||||
@@default = new
|
||||
end
|
||||
@@default.not_nil!
|
||||
end
|
||||
|
||||
def initialize
|
||||
register_mime_types
|
||||
|
||||
@dir = Config.current.library_path
|
||||
@scan_interval = Config.current.scan_interval
|
||||
# explicitly initialize @titles to bypass the compiler check. it will
|
||||
# be filled with actual Titles in the `scan` call below
|
||||
@title_ids = [] of String
|
||||
@title_hash = {} of String => Title
|
||||
|
||||
return scan if @scan_interval < 1
|
||||
spawn do
|
||||
loop do
|
||||
start = Time.local
|
||||
scan
|
||||
ms = (Time.local - start).total_milliseconds
|
||||
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 deep_titles
|
||||
titles + titles.map { |t| t.deep_titles }.flatten
|
||||
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)
|
||||
@title_hash[tid]?
|
||||
end
|
||||
|
||||
def get_title!(tid)
|
||||
@title_hash[tid]
|
||||
end
|
||||
|
||||
def scan
|
||||
unless Dir.exists? @dir
|
||||
Logger.info "The library directory #{@dir} does not exist. " \
|
||||
"Attempting to create it"
|
||||
Dir.mkdir_p @dir
|
||||
end
|
||||
@title_ids.clear
|
||||
|
||||
storage = Storage.new auto_close: false
|
||||
|
||||
(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, 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
|
||||
|
||||
storage.bulk_insert_ids
|
||||
storage.close
|
||||
|
||||
Logger.debug "Scan completed"
|
||||
end
|
||||
|
||||
def get_continue_reading_entries(username)
|
||||
cr_entries = deep_titles
|
||||
.map { |t| t.get_last_read_entry username }
|
||||
# Select elements with type `Entry` from the array and ignore all `Nil`s
|
||||
.select(Entry)[0..11]
|
||||
.map { |e|
|
||||
# Get the last read time of the entry. If it hasn't been started, get
|
||||
# the last read time of the previous entry
|
||||
last_read = e.load_last_read username
|
||||
pe = e.previous_entry
|
||||
if last_read.nil? && pe
|
||||
last_read = pe.load_last_read username
|
||||
end
|
||||
{
|
||||
entry: e,
|
||||
percentage: e.load_percentage(username),
|
||||
last_read: last_read,
|
||||
}
|
||||
}
|
||||
|
||||
# Sort by by last_read, most recent first (nils at the end)
|
||||
cr_entries.sort { |a, b|
|
||||
next 0 if a[:last_read].nil? && b[:last_read].nil?
|
||||
next 1 if a[:last_read].nil?
|
||||
next -1 if b[:last_read].nil?
|
||||
b[:last_read].not_nil! <=> a[:last_read].not_nil!
|
||||
}
|
||||
end
|
||||
|
||||
alias RA = NamedTuple(
|
||||
entry: Entry,
|
||||
percentage: Float64,
|
||||
grouped_count: Int32)
|
||||
|
||||
def get_recently_added_entries(username)
|
||||
recently_added = [] of RA
|
||||
last_date_added = nil
|
||||
|
||||
titles.map { |t| t.deep_entries_with_date_added }.flatten
|
||||
.select { |e| e[:date_added] > 1.month.ago }
|
||||
.sort { |a, b| b[:date_added] <=> a[:date_added] }
|
||||
.each do |e|
|
||||
break if recently_added.size > 12
|
||||
last = recently_added.last?
|
||||
if last && e[:entry].title_id == last[:entry].title_id &&
|
||||
(e[:date_added] - last_date_added.not_nil!).duration < 1.day
|
||||
# A NamedTuple is immutable, so we have to cast it to a Hash first
|
||||
last_hash = last.to_h
|
||||
count = last_hash[:grouped_count].as(Int32)
|
||||
last_hash[:grouped_count] = count + 1
|
||||
# Setting the percentage to a negative value will hide the
|
||||
# percentage badge on the card
|
||||
last_hash[:percentage] = -1.0
|
||||
recently_added[recently_added.size - 1] = RA.from last_hash
|
||||
else
|
||||
last_date_added = e[:date_added]
|
||||
recently_added << {
|
||||
entry: e[:entry],
|
||||
percentage: e[:entry].load_percentage(username),
|
||||
grouped_count: 1,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
recently_added[0..11]
|
||||
end
|
||||
|
||||
def sorted_titles(username, opt : SortOptions? = nil)
|
||||
if opt.nil?
|
||||
opt = SortOptions.from_info_json @dir, username
|
||||
else
|
||||
TitleInfo.new @dir do |info|
|
||||
info.sort_by[username] = opt.to_tuple
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
# This is a hack to bypass a compiler bug
|
||||
ary = titles
|
||||
|
||||
case opt.not_nil!.method
|
||||
when .time_modified?
|
||||
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
|
||||
compare_numerically a.title, b.title }
|
||||
when .progress?
|
||||
ary.sort! do |a, b|
|
||||
(a.load_percentage(username) <=> b.load_percentage(username)).or \
|
||||
compare_numerically a.title, b.title
|
||||
end
|
||||
else
|
||||
unless opt.method.auto?
|
||||
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||
"Auto instead"
|
||||
end
|
||||
ary.sort! { |a, b| compare_numerically a.title, b.title }
|
||||
end
|
||||
|
||||
ary.reverse! unless opt.not_nil!.ascend
|
||||
|
||||
ary
|
||||
end
|
||||
end
|
164
src/library/entry.cr
Normal file
164
src/library/entry.cr
Normal file
@ -0,0 +1,164 @@
|
||||
class Entry
|
||||
property zip_path : String, book : Title, title : String,
|
||||
size : String, pages : Int32, id : String, title_id : String,
|
||||
encoded_path : String, encoded_title : String, mtime : Time
|
||||
|
||||
def initialize(path, @book, @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 = ArchiveFile.new path
|
||||
@pages = file.entries.count do |e|
|
||||
SUPPORTED_IMG_TYPES.includes? \
|
||||
MIME.from_filename? e.filename
|
||||
end
|
||||
file.close
|
||||
id = storage.get_id @zip_path, false
|
||||
if id.nil?
|
||||
id = random_str
|
||||
storage.insert_id({
|
||||
path: @zip_path,
|
||||
id: id,
|
||||
is_title: false,
|
||||
})
|
||||
end
|
||||
@id = id
|
||||
@mtime = File.info(@zip_path).modification_time
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.object do
|
||||
{% for str in ["zip_path", "title", "size", "id", "title_id",
|
||||
"encoded_path", "encoded_title"] %}
|
||||
json.field {{str}}, @{{str.id}}
|
||||
{% end %}
|
||||
json.field "display_name", @book.display_name @title
|
||||
json.field "cover_url", cover_url
|
||||
json.field "pages" { json.number @pages }
|
||||
json.field "mtime" { json.number @mtime.to_unix }
|
||||
end
|
||||
end
|
||||
|
||||
def display_name
|
||||
@book.display_name @title
|
||||
end
|
||||
|
||||
def encoded_display_name
|
||||
URI.encode display_name
|
||||
end
|
||||
|
||||
def cover_url
|
||||
url = "#{Config.current.base_url}api/page/#{@title_id}/#{@id}/1"
|
||||
TitleInfo.new @book.dir do |info|
|
||||
info_url = info.entry_cover_url[@title]?
|
||||
unless info_url.nil? || info_url.empty?
|
||||
url = File.join Config.current.base_url, info_url
|
||||
end
|
||||
end
|
||||
url
|
||||
end
|
||||
|
||||
def read_page(page_num)
|
||||
img = nil
|
||||
ArchiveFile.open @zip_path do |file|
|
||||
page = file.entries
|
||||
.select { |e|
|
||||
SUPPORTED_IMG_TYPES.includes? \
|
||||
MIME.from_filename? e.filename
|
||||
}
|
||||
.sort { |a, b|
|
||||
compare_numerically a.filename, b.filename
|
||||
}
|
||||
.[page_num - 1]
|
||||
data = file.read_entry page
|
||||
if data
|
||||
img = Image.new data, MIME.from_filename(page.filename), page.filename,
|
||||
data.size
|
||||
end
|
||||
end
|
||||
img
|
||||
end
|
||||
|
||||
def next_entry(username)
|
||||
entries = @book.sorted_entries username
|
||||
idx = entries.index self
|
||||
return nil if idx.nil? || idx == entries.size - 1
|
||||
entries[idx + 1]
|
||||
end
|
||||
|
||||
def previous_entry
|
||||
idx = @book.entries.index self
|
||||
return nil if idx.nil? || idx == 0
|
||||
@book.entries[idx - 1]
|
||||
end
|
||||
|
||||
def date_added
|
||||
date_added = nil
|
||||
TitleInfo.new @book.dir do |info|
|
||||
info_da = info.date_added[@title]?
|
||||
if info_da.nil?
|
||||
date_added = info.date_added[@title] = ctime @zip_path
|
||||
info.save
|
||||
else
|
||||
date_added = info_da
|
||||
end
|
||||
end
|
||||
date_added.not_nil! # is it ok to set not_nil! here?
|
||||
end
|
||||
|
||||
# For backward backward compatibility with v0.1.0, we save entry titles
|
||||
# instead of IDs in info.json
|
||||
def save_progress(username, page)
|
||||
TitleInfo.new @book.dir do |info|
|
||||
if info.progress[username]?.nil?
|
||||
info.progress[username] = {@title => page}
|
||||
else
|
||||
info.progress[username][@title] = page
|
||||
end
|
||||
# save last_read timestamp
|
||||
if info.last_read[username]?.nil?
|
||||
info.last_read[username] = {@title => Time.utc}
|
||||
else
|
||||
info.last_read[username][@title] = Time.utc
|
||||
end
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
def load_progress(username)
|
||||
progress = 0
|
||||
TitleInfo.new @book.dir do |info|
|
||||
unless info.progress[username]?.nil? ||
|
||||
info.progress[username][@title]?.nil?
|
||||
progress = info.progress[username][@title]
|
||||
end
|
||||
end
|
||||
[progress, @pages].min
|
||||
end
|
||||
|
||||
def load_percentage(username)
|
||||
page = load_progress username
|
||||
page / @pages
|
||||
end
|
||||
|
||||
def load_last_read(username)
|
||||
last_read = nil
|
||||
TitleInfo.new @book.dir do |info|
|
||||
unless info.last_read[username]?.nil? ||
|
||||
info.last_read[username][@title]?.nil?
|
||||
last_read = info.last_read[username][@title]
|
||||
end
|
||||
end
|
||||
last_read
|
||||
end
|
||||
|
||||
def finished?(username)
|
||||
load_progress(username) == @pages
|
||||
end
|
||||
|
||||
def started?(username)
|
||||
load_progress(username) > 0
|
||||
end
|
||||
end
|
188
src/library/library.cr
Normal file
188
src/library/library.cr
Normal file
@ -0,0 +1,188 @@
|
||||
class Library
|
||||
property dir : String, title_ids : Array(String), scan_interval : Int32,
|
||||
title_hash : Hash(String, Title)
|
||||
|
||||
def self.default : self
|
||||
unless @@default
|
||||
@@default = new
|
||||
end
|
||||
@@default.not_nil!
|
||||
end
|
||||
|
||||
def initialize
|
||||
register_mime_types
|
||||
|
||||
@dir = Config.current.library_path
|
||||
@scan_interval = Config.current.scan_interval
|
||||
# explicitly initialize @titles to bypass the compiler check. it will
|
||||
# be filled with actual Titles in the `scan` call below
|
||||
@title_ids = [] of String
|
||||
@title_hash = {} of String => Title
|
||||
|
||||
return scan if @scan_interval < 1
|
||||
spawn do
|
||||
loop do
|
||||
start = Time.local
|
||||
scan
|
||||
ms = (Time.local - start).total_milliseconds
|
||||
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 deep_titles
|
||||
titles + titles.map { |t| t.deep_titles }.flatten
|
||||
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)
|
||||
@title_hash[tid]?
|
||||
end
|
||||
|
||||
def get_title!(tid)
|
||||
@title_hash[tid]
|
||||
end
|
||||
|
||||
def scan
|
||||
unless Dir.exists? @dir
|
||||
Logger.info "The library directory #{@dir} does not exist. " \
|
||||
"Attempting to create it"
|
||||
Dir.mkdir_p @dir
|
||||
end
|
||||
@title_ids.clear
|
||||
|
||||
storage = Storage.new auto_close: false
|
||||
|
||||
(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, 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
|
||||
|
||||
storage.bulk_insert_ids
|
||||
storage.close
|
||||
|
||||
Logger.debug "Scan completed"
|
||||
end
|
||||
|
||||
def get_continue_reading_entries(username)
|
||||
cr_entries = deep_titles
|
||||
.map { |t| t.get_last_read_entry username }
|
||||
# Select elements with type `Entry` from the array and ignore all `Nil`s
|
||||
.select(Entry)[0..11]
|
||||
.map { |e|
|
||||
# Get the last read time of the entry. If it hasn't been started, get
|
||||
# the last read time of the previous entry
|
||||
last_read = e.load_last_read username
|
||||
pe = e.previous_entry
|
||||
if last_read.nil? && pe
|
||||
last_read = pe.load_last_read username
|
||||
end
|
||||
{
|
||||
entry: e,
|
||||
percentage: e.load_percentage(username),
|
||||
last_read: last_read,
|
||||
}
|
||||
}
|
||||
|
||||
# Sort by by last_read, most recent first (nils at the end)
|
||||
cr_entries.sort { |a, b|
|
||||
next 0 if a[:last_read].nil? && b[:last_read].nil?
|
||||
next 1 if a[:last_read].nil?
|
||||
next -1 if b[:last_read].nil?
|
||||
b[:last_read].not_nil! <=> a[:last_read].not_nil!
|
||||
}
|
||||
end
|
||||
|
||||
alias RA = NamedTuple(
|
||||
entry: Entry,
|
||||
percentage: Float64,
|
||||
grouped_count: Int32)
|
||||
|
||||
def get_recently_added_entries(username)
|
||||
recently_added = [] of RA
|
||||
last_date_added = nil
|
||||
|
||||
titles.map { |t| t.deep_entries_with_date_added }.flatten
|
||||
.select { |e| e[:date_added] > 1.month.ago }
|
||||
.sort { |a, b| b[:date_added] <=> a[:date_added] }
|
||||
.each do |e|
|
||||
break if recently_added.size > 12
|
||||
last = recently_added.last?
|
||||
if last && e[:entry].title_id == last[:entry].title_id &&
|
||||
(e[:date_added] - last_date_added.not_nil!).duration < 1.day
|
||||
# A NamedTuple is immutable, so we have to cast it to a Hash first
|
||||
last_hash = last.to_h
|
||||
count = last_hash[:grouped_count].as(Int32)
|
||||
last_hash[:grouped_count] = count + 1
|
||||
# Setting the percentage to a negative value will hide the
|
||||
# percentage badge on the card
|
||||
last_hash[:percentage] = -1.0
|
||||
recently_added[recently_added.size - 1] = RA.from last_hash
|
||||
else
|
||||
last_date_added = e[:date_added]
|
||||
recently_added << {
|
||||
entry: e[:entry],
|
||||
percentage: e[:entry].load_percentage(username),
|
||||
grouped_count: 1,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
recently_added[0..11]
|
||||
end
|
||||
|
||||
def sorted_titles(username, opt : SortOptions? = nil)
|
||||
if opt.nil?
|
||||
opt = SortOptions.from_info_json @dir, username
|
||||
else
|
||||
TitleInfo.new @dir do |info|
|
||||
info.sort_by[username] = opt.to_tuple
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
# This is a hack to bypass a compiler bug
|
||||
ary = titles
|
||||
|
||||
case opt.not_nil!.method
|
||||
when .time_modified?
|
||||
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
|
||||
compare_numerically a.title, b.title }
|
||||
when .progress?
|
||||
ary.sort! do |a, b|
|
||||
(a.load_percentage(username) <=> b.load_percentage(username)).or \
|
||||
compare_numerically a.title, b.title
|
||||
end
|
||||
else
|
||||
unless opt.method.auto?
|
||||
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||
"Auto instead"
|
||||
end
|
||||
ary.sort! { |a, b| compare_numerically a.title, b.title }
|
||||
end
|
||||
|
||||
ary.reverse! unless opt.not_nil!.ascend
|
||||
|
||||
ary
|
||||
end
|
||||
end
|
367
src/library/title.cr
Normal file
367
src/library/title.cr
Normal file
@ -0,0 +1,367 @@
|
||||
require "../archive"
|
||||
|
||||
class Title
|
||||
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, @parent_id, storage,
|
||||
@library : Library)
|
||||
id = storage.get_id @dir, true
|
||||
if id.nil?
|
||||
id = random_str
|
||||
storage.insert_id({
|
||||
path: @dir,
|
||||
id: id,
|
||||
is_title: true,
|
||||
})
|
||||
end
|
||||
@id = id
|
||||
@title = File.basename dir
|
||||
@encoded_title = URI.encode @title
|
||||
@title_ids = [] of String
|
||||
@entries = [] of Entry
|
||||
@mtime = File.info(dir).modification_time
|
||||
|
||||
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, 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", ".rar", ".cbr"].includes? File.extname path
|
||||
unless File.readable? path
|
||||
Logger.warn "File #{path} is not readable. Please make sure the " \
|
||||
"file permission is configured correctly."
|
||||
next
|
||||
end
|
||||
archive_exception = validate_archive path
|
||||
unless archive_exception.nil?
|
||||
Logger.warn "Unable to extract archive #{path}. Ignoring it. " \
|
||||
"Archive error: #{archive_exception}"
|
||||
next
|
||||
end
|
||||
entry = Entry.new path, self, @id, storage
|
||||
@entries << entry if entry.pages > 0
|
||||
end
|
||||
end
|
||||
|
||||
mtimes = [@mtime]
|
||||
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime }
|
||||
mtimes += @entries.map { |e| e.mtime }
|
||||
@mtime = mtimes.max
|
||||
|
||||
@title_ids.sort! do |a, b|
|
||||
compare_numerically @library.title_hash[a].title,
|
||||
@library.title_hash[b].title
|
||||
end
|
||||
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
||||
@entries.sort! do |a, b|
|
||||
sorter.compare a.title, b.title
|
||||
end
|
||||
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 "display_name", display_name
|
||||
json.field "cover_url", cover_url
|
||||
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
|
||||
|
||||
# Get all entries, including entries in nested titles
|
||||
def deep_entries
|
||||
return @entries if title_ids.empty?
|
||||
@entries + titles.map { |t| t.deep_entries }.flatten
|
||||
end
|
||||
|
||||
def deep_titles
|
||||
return [] of Title if titles.empty?
|
||||
titles + titles.map { |t| t.deep_titles }.flatten
|
||||
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.reverse
|
||||
end
|
||||
|
||||
def size
|
||||
@entries.size + @title_ids.size
|
||||
end
|
||||
|
||||
def get_entry(eid)
|
||||
@entries.find { |e| e.id == eid }
|
||||
end
|
||||
|
||||
def display_name
|
||||
dn = @title
|
||||
TitleInfo.new @dir do |info|
|
||||
info_dn = info.display_name
|
||||
dn = info_dn unless info_dn.empty?
|
||||
end
|
||||
dn
|
||||
end
|
||||
|
||||
def encoded_display_name
|
||||
URI.encode display_name
|
||||
end
|
||||
|
||||
def display_name(entry_name)
|
||||
dn = entry_name
|
||||
TitleInfo.new @dir do |info|
|
||||
info_dn = info.entry_display_name[entry_name]?
|
||||
unless info_dn.nil? || info_dn.empty?
|
||||
dn = info_dn
|
||||
end
|
||||
end
|
||||
dn
|
||||
end
|
||||
|
||||
def set_display_name(dn)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.display_name = dn
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
def set_display_name(entry_name : String, dn)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.entry_display_name[entry_name] = dn
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
def cover_url
|
||||
url = "#{Config.current.base_url}img/icon.png"
|
||||
if @entries.size > 0
|
||||
url = @entries[0].cover_url
|
||||
end
|
||||
TitleInfo.new @dir do |info|
|
||||
info_url = info.cover_url
|
||||
unless info_url.nil? || info_url.empty?
|
||||
url = File.join Config.current.base_url, info_url
|
||||
end
|
||||
end
|
||||
url
|
||||
end
|
||||
|
||||
def set_cover_url(url : String)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.cover_url = url
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
def set_cover_url(entry_name : String, url : String)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.entry_cover_url[entry_name] = url
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
# Set the reading progress of all entries and nested libraries to 100%
|
||||
def read_all(username)
|
||||
@entries.each do |e|
|
||||
e.save_progress username, e.pages
|
||||
end
|
||||
titles.each do |t|
|
||||
t.read_all username
|
||||
end
|
||||
end
|
||||
|
||||
# Set the reading progress of all entries and nested libraries to 0%
|
||||
def unread_all(username)
|
||||
@entries.each do |e|
|
||||
e.save_progress username, 0
|
||||
end
|
||||
titles.each do |t|
|
||||
t.unread_all username
|
||||
end
|
||||
end
|
||||
|
||||
def deep_read_page_count(username) : Int32
|
||||
load_progress_for_all_entries(username).sum +
|
||||
titles.map { |t| t.deep_read_page_count username }.flatten.sum
|
||||
end
|
||||
|
||||
def deep_total_page_count : Int32
|
||||
entries.map { |e| e.pages }.sum +
|
||||
titles.map { |t| t.deep_total_page_count }.flatten.sum
|
||||
end
|
||||
|
||||
def load_percentage(username)
|
||||
deep_read_page_count(username) / deep_total_page_count
|
||||
end
|
||||
|
||||
def load_progress_for_all_entries(username, opt : SortOptions? = nil,
|
||||
unsorted = false)
|
||||
progress = {} of String => Int32
|
||||
TitleInfo.new @dir do |info|
|
||||
progress = info.progress[username]?
|
||||
end
|
||||
|
||||
if unsorted
|
||||
ary = @entries
|
||||
else
|
||||
ary = sorted_entries username, opt
|
||||
end
|
||||
|
||||
ary.map do |e|
|
||||
info_progress = 0
|
||||
if progress && progress.has_key? e.title
|
||||
info_progress = [progress[e.title], e.pages].min
|
||||
end
|
||||
info_progress
|
||||
end
|
||||
end
|
||||
|
||||
def load_percentage_for_all_entries(username, opt : SortOptions? = nil,
|
||||
unsorted = false)
|
||||
if unsorted
|
||||
ary = @entries
|
||||
else
|
||||
ary = sorted_entries username, opt
|
||||
end
|
||||
|
||||
progress = load_progress_for_all_entries username, opt, unsorted
|
||||
ary.map_with_index do |e, i|
|
||||
progress[i] / e.pages
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the sorted entries array
|
||||
#
|
||||
# When `opt` is nil, it uses the preferred sorting options in info.json, or
|
||||
# use the default (auto, ascending)
|
||||
# When `opt` is not nil, it saves the options to info.json
|
||||
def sorted_entries(username, opt : SortOptions? = nil)
|
||||
if opt.nil?
|
||||
opt = SortOptions.from_info_json @dir, username
|
||||
else
|
||||
TitleInfo.new @dir do |info|
|
||||
info.sort_by[username] = opt.to_tuple
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
case opt.not_nil!.method
|
||||
when .title?
|
||||
ary = @entries.sort { |a, b| compare_numerically a.title, b.title }
|
||||
when .time_modified?
|
||||
ary = @entries.sort { |a, b| (a.mtime <=> b.mtime).or \
|
||||
compare_numerically a.title, b.title }
|
||||
when .time_added?
|
||||
ary = @entries.sort { |a, b| (a.date_added <=> b.date_added).or \
|
||||
compare_numerically a.title, b.title }
|
||||
when .progress?
|
||||
percentage_ary = load_percentage_for_all_entries username, opt, true
|
||||
ary = @entries.zip(percentage_ary)
|
||||
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
|
||||
compare_numerically a_tp[0].title, b_tp[0].title }
|
||||
.map { |tp| tp[0] }
|
||||
else
|
||||
unless opt.method.auto?
|
||||
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||
"Auto instead"
|
||||
end
|
||||
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
||||
ary = @entries.sort do |a, b|
|
||||
sorter.compare a.title, b.title
|
||||
end
|
||||
end
|
||||
|
||||
ary.reverse! unless opt.not_nil!.ascend
|
||||
|
||||
ary
|
||||
end
|
||||
|
||||
# === helper methods ===
|
||||
|
||||
# Gets the last read entry in the title. If the entry has been completed,
|
||||
# returns the next entry. Returns nil when no entry has been read yet,
|
||||
# or when all entries are completed
|
||||
def get_last_read_entry(username) : Entry?
|
||||
progress = {} of String => Int32
|
||||
TitleInfo.new @dir do |info|
|
||||
progress = info.progress[username]?
|
||||
end
|
||||
return if progress.nil?
|
||||
|
||||
last_read_entry = nil
|
||||
|
||||
@entries.reverse_each do |e|
|
||||
if progress.has_key?(e.title) && progress[e.title] > 0
|
||||
last_read_entry = e
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if last_read_entry && last_read_entry.finished? username
|
||||
last_read_entry = last_read_entry.next_entry username
|
||||
end
|
||||
|
||||
last_read_entry
|
||||
end
|
||||
|
||||
# Equivalent to `@entries.map &. date_added`, but much more efficient
|
||||
def get_date_added_for_all_entries
|
||||
da = {} of String => Time
|
||||
TitleInfo.new @dir do |info|
|
||||
da = info.date_added
|
||||
end
|
||||
|
||||
@entries.each do |e|
|
||||
next if da.has_key? e.title
|
||||
da[e.title] = ctime e.zip_path
|
||||
end
|
||||
|
||||
TitleInfo.new @dir do |info|
|
||||
info.date_added = da
|
||||
info.save
|
||||
end
|
||||
|
||||
@entries.map { |e| da[e.title] }
|
||||
end
|
||||
|
||||
def deep_entries_with_date_added
|
||||
da_ary = get_date_added_for_all_entries
|
||||
zip = @entries.map_with_index do |e, i|
|
||||
{entry: e, date_added: da_ary[i]}
|
||||
end
|
||||
return zip if title_ids.empty?
|
||||
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten
|
||||
end
|
||||
end
|
102
src/library/types.cr
Normal file
102
src/library/types.cr
Normal file
@ -0,0 +1,102 @@
|
||||
SUPPORTED_IMG_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
||||
|
||||
enum SortMethod
|
||||
Auto
|
||||
Title
|
||||
Progress
|
||||
TimeModified
|
||||
TimeAdded
|
||||
end
|
||||
|
||||
class SortOptions
|
||||
property method : SortMethod, ascend : Bool
|
||||
|
||||
def initialize(in_method : String? = nil, @ascend = true)
|
||||
@method = SortMethod::Auto
|
||||
SortMethod.each do |m, _|
|
||||
if in_method && m.to_s.underscore == in_method
|
||||
@method = m
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(in_method : SortMethod? = nil, @ascend = true)
|
||||
if in_method
|
||||
@method = in_method
|
||||
else
|
||||
@method = SortMethod::Auto
|
||||
end
|
||||
end
|
||||
|
||||
def self.from_tuple(tp : Tuple(String, Bool))
|
||||
method, ascend = tp
|
||||
self.new method, ascend
|
||||
end
|
||||
|
||||
def self.from_info_json(dir, username)
|
||||
opt = SortOptions.new
|
||||
TitleInfo.new dir do |info|
|
||||
if info.sort_by.has_key? username
|
||||
opt = SortOptions.from_tuple info.sort_by[username]
|
||||
end
|
||||
end
|
||||
opt
|
||||
end
|
||||
|
||||
def to_tuple
|
||||
{@method.to_s.underscore, ascend}
|
||||
end
|
||||
end
|
||||
|
||||
struct Image
|
||||
property data : Bytes
|
||||
property mime : String
|
||||
property filename : String
|
||||
property size : Int32
|
||||
|
||||
def initialize(@data, @mime, @filename, @size)
|
||||
end
|
||||
end
|
||||
|
||||
class TitleInfo
|
||||
include JSON::Serializable
|
||||
|
||||
property comment = "Generated by Mango. DO NOT EDIT!"
|
||||
property progress = {} of String => Hash(String, Int32)
|
||||
property display_name = ""
|
||||
property entry_display_name = {} of String => String
|
||||
property cover_url = ""
|
||||
property entry_cover_url = {} of String => String
|
||||
property last_read = {} of String => Hash(String, Time)
|
||||
property date_added = {} of String => Time
|
||||
property sort_by = {} of String => Tuple(String, Bool)
|
||||
|
||||
@[JSON::Field(ignore: true)]
|
||||
property dir : String = ""
|
||||
|
||||
@@mutex_hash = {} of String => Mutex
|
||||
|
||||
def self.new(dir, &)
|
||||
if @@mutex_hash[dir]?
|
||||
mutex = @@mutex_hash[dir]
|
||||
else
|
||||
mutex = Mutex.new
|
||||
@@mutex_hash[dir] = mutex
|
||||
end
|
||||
mutex.synchronize do
|
||||
instance = TitleInfo.allocate
|
||||
json_path = File.join dir, "info.json"
|
||||
if File.exists? json_path
|
||||
instance = TitleInfo.from_json File.read json_path
|
||||
end
|
||||
instance.dir = dir
|
||||
yield instance
|
||||
end
|
||||
end
|
||||
|
||||
def save
|
||||
json_path = File.join @dir, "info.json"
|
||||
File.write json_path, self.to_pretty_json
|
||||
end
|
||||
end
|
@ -1,6 +1,6 @@
|
||||
require "kemal"
|
||||
require "kemal-session"
|
||||
require "./library"
|
||||
require "./library/*"
|
||||
require "./handlers/*"
|
||||
require "./util/*"
|
||||
require "./routes/*"
|
||||
|
Loading…
x
Reference in New Issue
Block a user