├── audiobookshelf_version.lua ├── .gitignore ├── audiobookshelf_config.example.lua ├── _meta.lua ├── CHANGELOG.md ├── .github └── workflows │ └── release.yml ├── main.lua ├── LICENSE ├── README.md └── audiobookshelf ├── audiobookshelfbrowser.lua ├── audiobookshelfapi.lua ├── ebookfilewidget.lua └── bookdetailswidget.lua /audiobookshelf_version.lua: -------------------------------------------------------------------------------- 1 | return { 0, 1, 3 } 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | audiobookshelf_config.lua 2 | audiobookshelf_config.lua.old 3 | -------------------------------------------------------------------------------- /audiobookshelf_config.example.lua: -------------------------------------------------------------------------------- 1 | -- plugins/audiobookshelf.koplugin/audiobookshelf_config.lua 2 | return { 3 | ["token"] = 'your api key here', 4 | ["server"] = 'your audiobookshelf instance url here' 5 | } 6 | -------------------------------------------------------------------------------- /_meta.lua: -------------------------------------------------------------------------------- 1 | local _ = require("gettext") 2 | return { 3 | name = "audiobookshelf", 4 | fullname = _("Audiobookshelf"), 5 | description = _("Download books from and synchronize progress with Audiobookshelf") 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.1.3 (2025-12-01) 2 | 3 | Merged a PR with a bunch of error handling (#12) 4 | 5 | ## 0.1.2 (2025-04-26) 6 | 7 | Fix issue #6 where if a book has an unsafe character in the filename. This sanitizes the name using util.getSafeFilename 8 | 9 | ## 0.1.1 (2025-04-25) 10 | 11 | Add search functionality 12 | 13 | Allow user to update settings from plugin 14 | 15 | ## 0.1.0 (2025-04-14) 16 | 17 | First functionality to allow user to download books 18 | 19 | ## 0.0.1 (2025-04-10) 20 | 21 | Initial release 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Releases 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: write 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Zip release 19 | run: cd .. && zip -r audiobookshelf.koplugin.zip audiobookshelf.koplugin -x '*.git*' 'audiobookshelf.koplugin/spec/*' 'audiobookshelf.koplugin/lua_modules/*' 'audiobookshelf.koplugin/.luarocks/*' 'audiobookshelf.koplugin/lua' 'audiobookshelf.koplugin/luarocks' 'audiobookshelf.koplugin/.tool-versions' '*.rockspec' && cd audiobookshelf.koplugin && mv ../audiobookshelf.koplugin.zip . 20 | 21 | - name: Release 22 | uses: softprops/action-gh-release@v2 23 | with: 24 | files: audiobookshelf.koplugin.zip 25 | -------------------------------------------------------------------------------- /main.lua: -------------------------------------------------------------------------------- 1 | local Dispatcher = require("dispatcher") 2 | local AudiobookshelfBrowser = require("audiobookshelf/audiobookshelfbrowser") 3 | local UIManager = require("ui/uimanager") 4 | local WidgetContainer = require("ui/widget/container/widgetcontainer") 5 | local _ = require("gettext") 6 | local logger = require("logger") 7 | 8 | local Audiobookshelf = WidgetContainer:extend{ 9 | name = "audiobookshelf", 10 | is_doc_only = false, 11 | } 12 | 13 | function Audiobookshelf:onDispatcherRegisterActions() 14 | -- none atm 15 | end 16 | 17 | function Audiobookshelf:init() 18 | self:onDispatcherRegisterActions() 19 | self.ui.menu:registerToMainMenu(self) 20 | end 21 | 22 | function Audiobookshelf:addToMainMenu(menu_items) 23 | menu_items.audiobookshelf = { 24 | text = _("Audiobookshelf"), 25 | sorting_hint = "tools", 26 | callback = function() 27 | UIManager:show(AudiobookshelfBrowser:new()) 28 | end 29 | } 30 | end 31 | 32 | return Audiobookshelf 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 naleo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Audiobookshelf for KOReader 2 | 3 | This is a a KOReader plugin to allow you to browse and download books from an Audiobookshelf Server 4 | 5 | ## Installation 6 | 7 | 1. Unzip the release into your KOReader plugins directory 8 | 2. Copy the `audiobookshelf_config.example.lua` file to `audiobookshelf_config.lua` 9 | 3. Add your Audiobookshelf user's API key as `token` in the config file. This can be found on your audiobookshelf server at Settings > Users > Click your username 10 | 4. Add the `server` to the config file, specifically the url of your server (no trailing slash) 11 | 12 | ## Usage 13 | 14 | The Audiobookshelf Browser can be found in the tools menu. 15 | 16 | ![FileManager_2025-04-14_164132](https://github.com/user-attachments/assets/99ccfb5c-67b7-47a9-bdd0-cca2ece99c4e) 17 | 18 | Once open, you can view the list of libraries available to your Audiobookshelf user 19 | 20 | ![FileManager_2025-04-14_171731](https://github.com/user-attachments/assets/09d924c7-96d1-41d1-b68e-614da964cd63) 21 | 22 | Click on one, and you will get the list of books available from that library (this only returns Audiobookshelf items that contain at least one eBook file) 23 | 24 | ![FileManager_2025-04-14_171903](https://github.com/user-attachments/assets/423a5c74-2578-4361-bc5a-acdfc3286ddf) 25 | 26 | If you click on a book, you go to the Book Details page, which gives details about the book, including a list of Downloadable files: 27 | 28 | ![FileManager_2025-04-14_172035](https://github.com/user-attachments/assets/43c94cb7-28d1-4931-a658-8e321c528ea9) 29 | 30 | Click on the name of one of the Downloadable files, and follow the process to download the eBook. 31 | 32 | ![FileManager_2025-04-14_172211](https://github.com/user-attachments/assets/a05ce960-48ae-4bf7-a5a3-54b161a3b211) 33 | -------------------------------------------------------------------------------- /audiobookshelf/audiobookshelfbrowser.lua: -------------------------------------------------------------------------------- 1 | local AudiobookshelfApi = require("audiobookshelf/audiobookshelfapi") 2 | local BookDetailsWidget = require("audiobookshelf/bookdetailswidget") 3 | local InfoMessage = require("ui/widget/infomessage") 4 | local InputDialog = require("ui/widget/inputdialog") 5 | local LuaSettings = require("luasettings") 6 | local Menu = require("ui/widget/menu") 7 | local MultiInputDialog = require("ui/widget/multiinputdialog") 8 | local UIManager = require("ui/uimanager") 9 | local _ = require("gettext") 10 | local logger = require("logger") 11 | 12 | local AudiobookshelfBrowser = Menu:extend{ 13 | no_title = false, 14 | title = _("Audiobookshelf Browser"), 15 | is_popout = false, 16 | is_borderless = true, 17 | title_bar_left_icon = "appbar.settings", 18 | show_parent = nil 19 | } 20 | 21 | -- levels: 22 | -- abs 23 | -- library 24 | function AudiobookshelfBrowser:init() 25 | self.abs_settings = LuaSettings:open("plugins/audiobookshelf.koplugin/audiobookshelf_config.lua") 26 | self.abs_settings:saveSetting("token", self.abs_settings:readSetting("token")) 27 | self.abs_settings:flush() 28 | self.show_parent = self 29 | self.level = "abs" 30 | if self.item then 31 | else 32 | self.item_table = self:genItemTableFromLibraries() 33 | end 34 | Menu.init(self) 35 | end 36 | 37 | function AudiobookshelfBrowser:genItemTableFromLibraries() 38 | local item_table = {} 39 | local libraries = AudiobookshelfApi:getLibraries() 40 | if not libraries then 41 | UIManager:show(InfoMessage:new{ 42 | text = _("Could not reach Audiobookshelf server. Check network and settings."), 43 | timeout = 2, 44 | }) 45 | return item_table 46 | end 47 | for _, library in ipairs(libraries) do 48 | table.insert(item_table, { 49 | text = library.name, 50 | type = "library", 51 | id = library.id, 52 | }) 53 | end 54 | return item_table 55 | end 56 | 57 | function AudiobookshelfBrowser:onMenuSelect(item) 58 | if item.type == "library" then 59 | table.insert(self.paths, { 60 | id = item.id, 61 | type = "library", 62 | name = item.text 63 | }) 64 | self:openLibrary(item.id, item.text) 65 | elseif item.type == "book" then 66 | -- Pass a zero-argument closure so the child calls the parent correctly 67 | local bookdetailswidget = BookDetailsWidget:new{ 68 | book_id = item.id, 69 | onCloseParent = function() 70 | self:onClose() 71 | end, 72 | } 73 | UIManager:show(bookdetailswidget, "flashui") 74 | end 75 | return true 76 | end 77 | 78 | function AudiobookshelfBrowser:onLeftButtonTap() 79 | if self.level == "abs" then 80 | self:configAudiobookshelf() 81 | elseif self.level == "library" then 82 | self:ShowSearch() 83 | end 84 | end 85 | 86 | function AudiobookshelfBrowser:configAudiobookshelf() 87 | local hint_server = "Audiobookshelf Server Url" 88 | local text_server = self.abs_settings:readSetting("server", "") 89 | local hint_token = "Audiobookshelf API Token" 90 | local text_token = self.abs_settings:readSetting("token", "") 91 | local title = "Audiobookshelf Settings" 92 | self.settings_dialog = MultiInputDialog:new { 93 | title = title, 94 | fields = { 95 | { 96 | text = text_server, 97 | input_type = "string", 98 | hint = hint_server 99 | }, 100 | { 101 | text = text_token, 102 | input_type = "string", 103 | hint = hint_token 104 | } 105 | }, 106 | buttons = { 107 | { 108 | { 109 | text = "Cancel", 110 | id = "close", 111 | callback = function() 112 | self.settings_dialog:onClose() 113 | UIManager:close(self.settings_dialog) 114 | end 115 | }, 116 | { 117 | text = "Save", 118 | callback = function() 119 | local fields = self.settings_dialog:getFields() 120 | logger.warn(fields) 121 | 122 | self.abs_settings:saveSetting("server", fields[1]) 123 | self.abs_settings:saveSetting("token", fields[2]) 124 | self.abs_settings:flush() 125 | 126 | self.settings_dialog:onClose() 127 | UIManager:close(self.settings_dialog) 128 | 129 | UIManager:show(InfoMessage:new{ 130 | text = "Settings saved", 131 | timeout = 1 132 | }) 133 | end 134 | } 135 | } 136 | } 137 | } 138 | UIManager:show(self.settings_dialog) 139 | self.settings_dialog:onShowKeyboard() 140 | end 141 | 142 | function AudiobookshelfBrowser:ShowSearch() 143 | self.search_dialog = InputDialog:new{ 144 | title = "Search", 145 | input = self.search_value, 146 | buttons = { 147 | { 148 | { 149 | text = "Cancel", 150 | id = "close", 151 | enabled = true, 152 | callback = function() 153 | self.search_dialog:onClose() 154 | UIManager:close(self.search_dialog) 155 | end 156 | }, 157 | { 158 | text = "Search", 159 | enabled = true, 160 | callback = function() 161 | self.search_value = self.search_dialog:getInputText() 162 | self:search() 163 | end 164 | } 165 | } 166 | } 167 | } 168 | UIManager:show(self.search_dialog) 169 | self.search_dialog:onShowKeyboard() 170 | end 171 | 172 | function AudiobookshelfBrowser:search() 173 | if self.search_value then 174 | self.search_dialog:onClose() 175 | UIManager:close(self.search_dialog) 176 | if string.len(self.search_value) > 0 then 177 | self:loadLibrarySearch(self.search_value) 178 | end 179 | end 180 | end 181 | 182 | function AudiobookshelfBrowser:loadLibrarySearch(search) 183 | local tbl = {} 184 | local libraryItems = AudiobookshelfApi:getSearchResults(self.library_id, search) 185 | if not libraryItems or not libraryItems.book then 186 | UIManager:show(InfoMessage:new{ 187 | text = _("Search failed. Check network and settings."), 188 | timeout = 2, 189 | }) 190 | return 191 | end 192 | logger.warn(libraryItems) 193 | for _, item in ipairs(libraryItems.book) do 194 | table.insert(tbl, { 195 | id = item.libraryItem.id, 196 | text = item.libraryItem.media.metadata.title, 197 | mandatory = item.libraryItem.media.metadata.authorName, 198 | type = "book" 199 | }) 200 | end 201 | 202 | self:setTitleBarLeftIcon("appbar.search") 203 | self:switchItemTable("Search Results", tbl) 204 | end 205 | 206 | function AudiobookshelfBrowser:openLibrary(id, name) 207 | local tbl = {} 208 | local libraryItems = AudiobookshelfApi:getLibraryItems(id) 209 | if not libraryItems then 210 | UIManager:show(InfoMessage:new{ 211 | text = _("Could not load library. Check network and settings."), 212 | timeout = 2, 213 | }) 214 | return false 215 | end 216 | for _, item in ipairs(libraryItems) do 217 | table.insert(tbl, { 218 | id = item.id, 219 | text = item.media.metadata.title, 220 | mandatory = item.media.metadata.authorName, 221 | type = "book" 222 | }) 223 | end 224 | 225 | self.library_id = id 226 | self.level = "library" 227 | self:setTitleBarLeftIcon("appbar.search") 228 | self:switchItemTable(name, tbl) 229 | return true 230 | end 231 | 232 | return AudiobookshelfBrowser -------------------------------------------------------------------------------- /audiobookshelf/audiobookshelfapi.lua: -------------------------------------------------------------------------------- 1 | local config = require("audiobookshelf_config") 2 | local T = require("ffi/util").template 3 | local JSON = require("json") 4 | local http = require("socket.http") 5 | local ltn12 = require("ltn12") 6 | local socketutil = require("socketutil") 7 | local socket = require("socket") 8 | local logger = require("logger") 9 | local RenderImage = require("ui/renderimage") 10 | local util = require("util") 11 | local LuaSettings = require("luasettings") 12 | 13 | local VERSION = require("audiobookshelf_version") 14 | 15 | local AudiobookshelfApi = { 16 | abs_settings = LuaSettings:open("plugins/audiobookshelf.koplugin/audiobookshelf_config.lua") 17 | } 18 | 19 | function AudiobookshelfApi:getLibraries() 20 | local sink = {} 21 | local request = { 22 | url = self.abs_settings:readSetting("server") .. "/api/libraries", 23 | method = "GET", 24 | headers = { 25 | ["Authorization"] = "Bearer " .. self.abs_settings:readSetting("token"), 26 | ["User-Agent"] = T("audiobookshelf.koplugin/%1", table.concat(VERSION, ".")), 27 | }, 28 | sink = ltn12.sink.table(sink), 29 | } 30 | socketutil:set_timeout() 31 | local ok, code, _, status = pcall(function() return socket.skip(1, http.request(request)) end) 32 | local response = table.concat(sink) 33 | socketutil:reset_timeout() 34 | if not ok then 35 | logger.warn("AudiobookshelfApi: http request failed in getLibraries:", code) 36 | return nil 37 | end 38 | if code == 200 and response ~= "" then 39 | local _, result = pcall(JSON.decode, response) 40 | return result.libraries 41 | end 42 | logger.warn("AudiobookshelfApi: cannot get libraries", status or code) 43 | logger.warn("AudiobookshelfApi: error:", response) 44 | return nil 45 | end 46 | 47 | function AudiobookshelfApi:getLibraryItems(id) 48 | local sink = {} 49 | -- this is "ebooks" base64 encoded, and the URL encoded, to only return library items with ebooks 50 | local filters = "ebooks." .. "ZWJvb2s%3D" 51 | local request = { 52 | url = self.abs_settings:readSetting("server") .. "/api/libraries/" .. id .. "/items?filter=" .. filters .. "&sort=media.metadata.title&limit=0", 53 | method = "GET", 54 | headers = { 55 | ["Authorization"] = "Bearer " .. self.abs_settings:readSetting("token"), 56 | ["User-Agent"] = T("audiobookshelf.koplugin/%1", table.concat(VERSION, ".")), 57 | }, 58 | sink = ltn12.sink.table(sink), 59 | } 60 | socketutil:set_timeout() 61 | local ok, code, _, status = pcall(function() return socket.skip(1, http.request(request)) end) 62 | local response = table.concat(sink) 63 | socketutil:reset_timeout() 64 | if not ok then 65 | logger.warn("AudiobookshelfApi: http request failed in getLibraryItems:", code) 66 | return nil 67 | end 68 | if code == 200 and response ~= "" then 69 | local _, result = pcall(JSON.decode, response, JSON.decode.simple) 70 | return result.results 71 | end 72 | logger.warn("AudiobookshelfApi: cannot get library items for library", id ,status or code) 73 | logger.warn("AudiobookshelfApi: error:", response) 74 | return nil 75 | end 76 | 77 | function AudiobookshelfApi:getLibraryItem(id) 78 | local sink = {} 79 | local request = { 80 | url = self.abs_settings:readSetting("server") .. "/api/items/" .. id .. "?expanded=1", 81 | method = "GET", 82 | headers = { 83 | ["Authorization"] = "Bearer " .. self.abs_settings:readSetting("token"), 84 | ["User-Agent"] = T("audiobookshelf.koplugin/%1", table.concat(VERSION, ".")), 85 | }, 86 | sink = ltn12.sink.table(sink), 87 | } 88 | socketutil:set_timeout() 89 | local ok, code, _, status = pcall(function() return socket.skip(1, http.request(request)) end) 90 | local response = table.concat(sink) 91 | socketutil:reset_timeout() 92 | if not ok then 93 | logger.warn("AudiobookshelfApi: http request failed in getLibraryItem:", code) 94 | return nil 95 | end 96 | if code == 200 and response ~= "" then 97 | local _, result = pcall(JSON.decode, response, JSON.decode.simple) 98 | return result 99 | end 100 | logger.warn("AudiobookshelfApi: cannot get library item", id ,status or code) 101 | logger.warn("AudiobookshelfApi: error:", response) 102 | return nil 103 | end 104 | 105 | function AudiobookshelfApi:downloadFile(id, ino, filename, local_path) 106 | socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT) 107 | local outfile, err = io.open(local_path .. "/" .. filename, "w") 108 | if not outfile then 109 | logger.warn("AudiobookshelfApi: cannot open local file for writing:", local_path .. "/" .. filename, err) 110 | socketutil:reset_timeout() 111 | return nil 112 | end 113 | local request = { 114 | url = self.abs_settings:readSetting("server") .. "/api/items/" .. id .. "/file/" .. ino .. "/download", 115 | method = "GET", 116 | headers = { 117 | ["Authorization"] = "Bearer " .. self.abs_settings:readSetting("token"), 118 | ["User-Agent"] = T("audiobookshelf.koplugin/%1", table.concat(VERSION, ".")), 119 | }, 120 | sink = ltn12.sink.file(outfile), 121 | } 122 | local ok, code, _, status = pcall(function() return socket.skip(1, http.request(request)) end) 123 | socketutil:reset_timeout() 124 | if not ok or code ~= 200 then 125 | logger.warn("AudiobookshelfApi: cannot download file:", id , ino, status or code) 126 | end 127 | return code 128 | end 129 | 130 | function AudiobookshelfApi:getLibraryItemCover(id) 131 | local sink = {} 132 | local request = { 133 | url = self.abs_settings:readSetting("server") .. "/api/items/" .. id .. "/cover?format=webp", 134 | method = "GET", 135 | headers = { 136 | ["Authorization"] = "Bearer " .. self.abs_settings:readSetting("token"), 137 | ["User-Agent"] = T("audiobookshelf.koplugin/%1", table.concat(VERSION, ".")), 138 | }, 139 | sink = ltn12.sink.table(sink), 140 | } 141 | socketutil:set_timeout() 142 | local ok, code, _, status = pcall(function() return socket.skip(1, http.request(request)) end) 143 | local response = table.concat(sink) 144 | socketutil:reset_timeout() 145 | if not ok then 146 | logger.warn("AudiobookshelfApi: http request failed in getLibraryItemCover:", code) 147 | return nil 148 | end 149 | if code == 200 and response ~= "" then 150 | local result = RenderImage:renderImageData(response, #response) 151 | return result 152 | end 153 | logger.warn("AudiobookshelfApi: cannot get library item cover", id ,status or code) 154 | logger.warn("AudiobookshelfApi: error:", response) 155 | return nil 156 | end 157 | 158 | function AudiobookshelfApi:getSearchResults(id, search_query) 159 | local sink = {} 160 | local url_encoded_search_string = util.urlEncode(search_query) 161 | -- this is "ebooks" base64 encoded, and the URL encoded, to only return library items with ebooks 162 | local filters = "ebooks." .. "ZWJvb2s%3D" 163 | local request = { 164 | url = self.abs_settings:readSetting("server") .. "/api/libraries/" .. id .. "/search?q=" .. url_encoded_search_string .. "&filter=" .. filters, 165 | method = "GET", 166 | headers = { 167 | ["Authorization"] = "Bearer " .. self.abs_settings:readSetting("token"), 168 | ["User-Agent"] = T("audiobookshelf.koplugin/%1", table.concat(VERSION, ".")), 169 | }, 170 | sink = ltn12.sink.table(sink), 171 | } 172 | socketutil:set_timeout() 173 | local ok, code, _, status = pcall(function() return socket.skip(1, http.request(request)) end) 174 | local response = table.concat(sink) 175 | socketutil:reset_timeout() 176 | if not ok then 177 | logger.warn("AudiobookshelfApi: http request failed in getSearchResults:", code) 178 | return nil 179 | end 180 | if code == 200 and response ~= "" then 181 | local _, result = pcall(JSON.decode, response, JSON.decode.simple) 182 | return result 183 | end 184 | logger.warn("AudiobookshelfApi: cannot search library", id ,search_query, status or code) 185 | logger.warn("AudiobookshelfApi: error:", response) 186 | return nil 187 | end 188 | 189 | return AudiobookshelfApi -------------------------------------------------------------------------------- /audiobookshelf/ebookfilewidget.lua: -------------------------------------------------------------------------------- 1 | local AudiobookshelfApi = require("audiobookshelf/audiobookshelfapi") 2 | local BD = require("ui/bidi") 3 | local Blitbuffer = require("ffi/blitbuffer") 4 | local ButtonDialog = require("ui/widget/buttondialog") 5 | local CenterContainer = require("ui/widget/container/centercontainer") 6 | local ConfirmBox = require("ui/widget/confirmbox") 7 | local DataStorage = require("datastorage") 8 | local DocumentRegistry = require("document/documentregistry") 9 | local Font = require("ui/font") 10 | local Geom = require("ui/geometry") 11 | local GestureRange = require("ui/gesturerange") 12 | local HorizontalGroup = require("ui/widget/horizontalgroup") 13 | local InfoMessage = require("ui/widget/infomessage") 14 | local InputContainer = require("ui/widget/container/inputcontainer") 15 | local InputDialog = require("ui/widget/inputdialog") 16 | local LeftContainer = require("ui/widget/container/leftcontainer") 17 | local LuaSettings = require("luasettings") 18 | local OverlapGroup = require("ui/widget/overlapgroup") 19 | local ReaderUI = require("apps/reader/readerui") 20 | local RightContainer = require("ui/widget/container/rightcontainer") 21 | local Size = require("ui/size") 22 | local TextBoxWidget = require("ui/widget/textboxwidget") 23 | local TextWidget = require("ui/widget/textwidget") 24 | local UIManager = require("ui/uimanager") 25 | local UnderlineContainer = require("ui/widget/container/underlinecontainer") 26 | local util = require("util") 27 | local logger = require("logger") 28 | local lfs = require("libs/libkoreader-lfs") 29 | local T = require("ffi/util").template 30 | local _ = require("gettext") 31 | 32 | local EbookFileWidget = InputContainer:extend{ 33 | filename = nil, 34 | ino = nil, 35 | size_in_bytes = 0, 36 | book_id = nil, 37 | width = nil, 38 | side_margin = Size.padding.fullscreen, 39 | onClose = nil -- function to close whole parent menu path after download 40 | } 41 | 42 | function EbookFileWidget:readSettings() 43 | self.abs_settings = LuaSettings:open("plugins/audiobookshelf.koplugin/audiobookshelf_config.lua") 44 | return self.abs_settings 45 | end 46 | 47 | function EbookFileWidget:init() 48 | self.small_font = Font:getFace("smallffont") 49 | self.medium_font = Font:getFace("ffont") 50 | self.large_font = Font:getFace("largeffont") 51 | 52 | local first_text = TextBoxWidget:new{ 53 | text = self.filename or "", 54 | face = self.small_font, 55 | width = (self.width - self.side_margin * 2) * 0.75 56 | } 57 | local content_height = first_text:getSize().h 58 | local left_container = LeftContainer:new{ 59 | dimen = Geom:new{w = self.width - self.side_margin * 2, h = content_height}, 60 | first_text 61 | } 62 | local last_text = TextWidget:new{ 63 | text = util.getFriendlySize(self.size_in_bytes), 64 | face = self.small_font 65 | } 66 | local right_container = RightContainer:new{ 67 | dimen = Geom:new{w = self.width - self.side_margin * 2, h = content_height}, 68 | last_text, 69 | } 70 | local overlay_container = OverlapGroup:new{ 71 | dimen = Geom:new{w = self.width - self.side_margin * 2, h = content_height}, 72 | left_container, 73 | right_container 74 | } 75 | 76 | local underline_container = UnderlineContainer:new{ 77 | linesize = Size.line.thin, 78 | padding = Size.padding.default, 79 | vertical_align = "center", 80 | color = Blitbuffer.COLOR_DARK_GRAY, 81 | overlay_container 82 | } 83 | self[1] = CenterContainer:new{ 84 | dimen = Geom:new{ w = self.width, h = underline_container:getSize().h }, 85 | underline_container 86 | } 87 | 88 | self.dimen = Geom:new{ w = self.width, h = underline_container:getSize().h } 89 | self.ges_events = { 90 | TapSelect = { 91 | GestureRange:new{ 92 | ges = "tap", 93 | range = self.dimen 94 | }, 95 | }, 96 | HoldSelect = { 97 | GestureRange:new{ 98 | ges = self.handle_hold_on_hold_release and "hold_release" or "hold", 99 | range = self.dimen 100 | }, 101 | }, 102 | } 103 | end 104 | 105 | function EbookFileWidget:onTapSelect() 106 | -- make sure it exists first 107 | if not self[1].dimen then return end 108 | self:downloadFile() 109 | end 110 | 111 | function EbookFileWidget:onHoldSelect() 112 | -- make sure it exists first 113 | if not self[1].dimen then return end 114 | -- stub for adding long hold functionality 115 | logger.warn(self.book_id, self.ino) 116 | end 117 | 118 | function EbookFileWidget:downloadFile() 119 | local function startDownloadFile(filename, path, callback_close) 120 | local safeFilename = util.getSafeFilename(filename, path, 230) 121 | UIManager:scheduleIn(1, function() 122 | local code = AudiobookshelfApi:downloadFile(self.book_id, self.ino, safeFilename, path) 123 | if code == 200 then 124 | local confirm = ConfirmBox:new{ 125 | text = T(_("File saved to:\n%1\nWould you like to read the downloaded book now?"), 126 | BD.filepath(path .. "/" .. safeFilename)), 127 | ok_callback = function() 128 | local Event = require("ui/event") 129 | UIManager:broadcastEvent(Event:new("SetupShowReader")) 130 | 131 | if callback_close then 132 | callback_close() 133 | end 134 | ReaderUI:showReader(path .. "/" .. safeFilename) 135 | end 136 | } 137 | -- force full refresh / flash to avoid clipped rendering 138 | UIManager:show(confirm, "flashui") 139 | else 140 | local info_err = InfoMessage:new{ 141 | text = T(_("Could not save file to:\n%1"), BD.filepath(path)), 142 | timeout = 3, 143 | } 144 | UIManager:show(info_err, "flashui") 145 | end 146 | end) 147 | local info_down = InfoMessage:new{ 148 | text = _("Downloading. This might take a moment."), 149 | timeout = 1, 150 | } 151 | UIManager:show(info_down, "flashui") 152 | end 153 | 154 | local function genTitle(original_filename, size, filename, path) 155 | local filesize_str = self.size_in_bytes and util.getFriendlySize(self.size_in_bytes) or _("N/A") 156 | 157 | return T(_("Filename:\n%1\n\nFile size:\n%2\n\nDownload filename:\n%3\n\nDownload folder:\n%4"), 158 | original_filename, filesize_str, filename, BD.dirpath(path)) 159 | end 160 | 161 | local abs_settings = self:readSettings() 162 | local download_dir = abs_settings:readSetting("download_dir") or G_reader_settings:readSetting("lastdir") 163 | local chosen_filename = self.filename 164 | 165 | local buttons = { 166 | { 167 | { 168 | text = "Choose folder", 169 | callback = function() 170 | require("ui/downloadmgr"):new{ 171 | onConfirm = function(path) 172 | abs_settings:saveSetting("download_dir", path) 173 | abs_settings:flush() 174 | download_dir = path 175 | self.download_dialog:setTitle(genTitle(self.filename, self.size_in_bytes, chosen_filename, path)) 176 | end, 177 | }:chooseDir(download_dir) 178 | end, 179 | }, 180 | { 181 | text = _("Change filename"), 182 | callback = function() 183 | local input_dialog 184 | input_dialog = InputDialog:new{ 185 | title = _("Enter filename"), 186 | input = chosen_filename, 187 | input_hint = self.filename, 188 | buttons = { 189 | { 190 | { 191 | text = _("Cancel"), 192 | id = "close", 193 | callback = function() 194 | UIManager:close(input_dialog) 195 | end, 196 | }, 197 | { 198 | text = _("Set filename"), 199 | is_enter_default = true, 200 | callback = function() 201 | chosen_filename = input_dialog:getInputValue() 202 | if chosen_filename == "" then 203 | chosen_filename = self.filename 204 | end 205 | UIManager:close(input_dialog) 206 | self.download_dialog:setTitle(genTitle(self.filename, self.size_in_bytes, chosen_filename, download_dir)) 207 | end, 208 | }, 209 | } 210 | }, 211 | } 212 | UIManager:show(input_dialog) 213 | input_dialog:onShowKeyboard() 214 | end, 215 | }, 216 | }, 217 | { 218 | { 219 | text = _("Cancel"), 220 | callback = function() 221 | UIManager:close(self.download_dialog) 222 | end, 223 | }, 224 | { 225 | text = _("Download"), 226 | callback = function() 227 | UIManager:close(self.download_dialog) 228 | -- call parent close callback safely without passing this widget as `self` 229 | local callback_close = function() 230 | if type(self.onClose) == "function" then 231 | self.onClose() 232 | end 233 | end 234 | 235 | -- ensure chosen_filename is sanitized for existence check 236 | local safeFilename = util.getSafeFilename(chosen_filename, download_dir, 230) 237 | local fullpath = (download_dir == "/" and ("/" .. safeFilename)) or (download_dir .. "/" .. safeFilename) 238 | 239 | if lfs.attributes(fullpath) then 240 | UIManager:show(ConfirmBox:new{ 241 | text = _("File already exists. Would you like to overwrite it?"), 242 | ok_callback = function() 243 | startDownloadFile(chosen_filename, download_dir, callback_close) 244 | end 245 | }, "flashui") 246 | else 247 | startDownloadFile(chosen_filename, download_dir, callback_close) 248 | end 249 | end, 250 | }, 251 | }, 252 | } 253 | 254 | self.download_dialog = ButtonDialog:new{ 255 | title = genTitle(self.filename, self.size_in_bytes, chosen_filename, download_dir), 256 | buttons = buttons, 257 | } 258 | UIManager:show(self.download_dialog) 259 | end 260 | 261 | return EbookFileWidget -------------------------------------------------------------------------------- /audiobookshelf/bookdetailswidget.lua: -------------------------------------------------------------------------------- 1 | local Blitbuffer = require("ffi/blitbuffer") 2 | local CenterContainer = require("ui/widget/container/centercontainer") 3 | local EbookFileWidget = require("audiobookshelf/ebookfilewidget") 4 | local FrameContainer = require("ui/widget/container/framecontainer") 5 | local FocusManager = require("ui/widget/focusmanager") 6 | local Font = require("ui/font") 7 | local Geom = require("ui/geometry") 8 | local HorizontalGroup = require("ui/widget/horizontalgroup") 9 | local HorizontalSpan = require("ui/widget/horizontalspan") 10 | local ImageWidget = require("ui/widget/imagewidget") 11 | local LeftContainer = require("ui/widget/container/leftcontainer") 12 | local LineWidget = require("ui/widget/linewidget") 13 | local ListView = require("ui/widget/listview") 14 | local RenderImage = require("ui/renderimage") 15 | local Size = require("ui/size") 16 | local ScrollTextWidget = require("ui/widget/scrolltextwidget") 17 | local TitleBar = require("ui/widget/titlebar") 18 | local TextBoxWidget = require("ui/widget/textboxwidget") 19 | local TextWidget = require("ui/widget/textwidget") 20 | local UIManager = require("ui/uimanager") 21 | local VerticalGroup = require("ui/widget/verticalgroup") 22 | local VerticalSpan = require("ui/widget/verticalspan") 23 | 24 | local Device = require("device") 25 | local Screen = Device.screen 26 | 27 | local AudiobookshelfApi = require("audiobookshelf/audiobookshelfapi") 28 | local InfoMessage = require("ui/widget/infomessage") 29 | 30 | local BookDetailsWidget = FocusManager:extend{ 31 | padding = Size.padding.fullscreen, 32 | onCloseParent = nil, 33 | } 34 | 35 | function BookDetailsWidget:onClose() 36 | UIManager:close(self) 37 | if self.onCloseParent then 38 | -- call as a zero-argument function to avoid passing this widget as `self` 39 | self.onCloseParent() 40 | end 41 | end 42 | 43 | function BookDetailsWidget:init() 44 | self.layout = {} 45 | 46 | self.book_info = AudiobookshelfApi:getLibraryItem(self.book_id) 47 | if not self.book_info then 48 | UIManager:show(InfoMessage:new{ 49 | text = _("Could not load book details. Check network and settings."), 50 | timeout = 2, 51 | }) 52 | -- close the widget to avoid crashes elsewhere 53 | UIManager:close(self) 54 | return 55 | end 56 | 57 | self.small_font = Font:getFace("smallffont") 58 | self.medium_font = Font:getFace("ffont") 59 | self.large_font = Font:getFace("largeffont") 60 | 61 | 62 | local screen_size = Screen:getSize() 63 | self.covers_fullscreen = true 64 | self[1] = FrameContainer:new{ 65 | width = screen_size.w, 66 | height = screen_size.h, 67 | background = Blitbuffer.COLOR_WHITE, 68 | bordersize = 0, 69 | padding = 0, 70 | self:getDetailsContent(screen_size.w) 71 | } 72 | 73 | self.dithered = true 74 | end 75 | 76 | function BookDetailsWidget:getDetailsContent(width) 77 | local title_bar = TitleBar:new{ 78 | width = width, 79 | bottom_v_padding = 0, 80 | close_callback = function() self:onClose() end, 81 | show_parent = self, 82 | } 83 | 84 | local content = VerticalGroup:new{ 85 | align = "left", 86 | title_bar, 87 | self:genBookDetails(), 88 | self:genHeader("Description"), 89 | self:genDescriptionContent(), 90 | self:genHeader("Files"), 91 | self:genFileList(), 92 | } 93 | return content 94 | end 95 | 96 | function BookDetailsWidget:genFileList() 97 | local screen_height = Screen:getHeight() 98 | local screen_width = Screen:getWidth() 99 | local list = VerticalGroup:new{ 100 | height = screen_height * 0.2, 101 | } 102 | for _, file in ipairs(self.book_info.libraryFiles) do 103 | if file.fileType == "ebook" then 104 | table.insert(list, EbookFileWidget:new{ 105 | width = screen_width, 106 | ino = file.ino, 107 | filename = file.metadata.filename, 108 | size_in_bytes = file.metadata.size, 109 | book_id = self.book_info.id, 110 | -- pass a zero-arg closure that will call this widget's onClose 111 | onClose = function() 112 | self:onClose() 113 | end, 114 | }) 115 | end 116 | end 117 | return list 118 | end 119 | 120 | function BookDetailsWidget:genBookDetails() 121 | local screen_width = Screen:getWidth() 122 | 123 | local img_width, img_height 124 | if Screen:getScreenMode() == "landscape" then 125 | img_width = Screen:scaleBySize(132) 126 | img_height = Screen:scaleBySize(184) 127 | else 128 | img_width = Screen:scaleBySize(132 * 1.5) 129 | img_height = Screen:scaleBySize(184 * 1.5) 130 | end 131 | 132 | local book_authors = {} 133 | for _, author in ipairs(self.book_info.media.metadata.authors) do 134 | table.insert(book_authors, author.name) 135 | end 136 | local book_author_string = table.concat(book_authors, ", ") 137 | 138 | local book_metadata_group = VerticalGroup:new{ 139 | align = "left", 140 | VerticalSpan:new{ width = img_height * 0.15}, 141 | } 142 | table.insert(book_metadata_group, 143 | TextBoxWidget:new{ -- book title 144 | text = self.book_info.media.metadata.title, 145 | face = self.medium_font, 146 | alignment = "left", 147 | } 148 | ) 149 | if self.book_info.media.metadata.seriesName ~= "" then 150 | table.insert(book_metadata_group, 151 | TextBoxWidget:new{ -- book series (if applicable) 152 | text = self.book_info.media.metadata.seriesName, 153 | face = self.small_font, 154 | alignment = "left", 155 | } 156 | ) 157 | end 158 | table.insert(book_metadata_group, 159 | TextBoxWidget:new{ -- book author 160 | text = "by " .. book_author_string, 161 | face = self.small_font, 162 | alignment = "left", 163 | } 164 | ) 165 | local metadata_label_group = VerticalGroup:new{ 166 | align = "left", 167 | TextWidget:new{ -- book publish year 168 | text = "Publish year", 169 | face = self.small_font, 170 | alignment = "left", 171 | fgcolor = Blitbuffer.COLOR_GRAY_9, 172 | }, 173 | TextWidget:new{ -- book publish year 174 | text = "Publisher", 175 | face = self.small_font, 176 | alignment = "left", 177 | fgcolor = Blitbuffer.COLOR_GRAY_9, 178 | }, 179 | TextWidget:new{ -- book publish year 180 | text = "Genres", 181 | face = self.small_font, 182 | alignment = "left", 183 | fgcolor = Blitbuffer.COLOR_GRAY_9, 184 | }, 185 | TextWidget:new{ -- book publish year 186 | text = "Language", 187 | face = self.small_font, 188 | alignment = "left", 189 | fgcolor = Blitbuffer.COLOR_GRAY_9, 190 | }, 191 | } 192 | 193 | local metadata_labeled_group = VerticalGroup:new{ 194 | align = "left", 195 | TextWidget:new{ -- book publish year 196 | text = self.book_info.media.metadata.publishedYear or "", 197 | face = self.small_font, 198 | alignment = "left", 199 | }, 200 | TextWidget:new{ -- book publish year 201 | text = self.book_info.media.metadata.publisher or "", 202 | face = self.small_font, 203 | alignment = "left", 204 | }, 205 | TextWidget:new{ -- book publish year 206 | text = table.concat(self.book_info.media.metadata.genres, ", ") or "", 207 | face = self.small_font, 208 | alignment = "left", 209 | }, 210 | TextWidget:new{ -- book publish year 211 | text = self.book_info.media.metadata.language or "", 212 | face = self.small_font, 213 | alignment = "left", 214 | }, 215 | } 216 | 217 | local extra_metadata_group = HorizontalGroup:new{ 218 | align = "top", 219 | metadata_label_group, 220 | HorizontalSpan:new{ width = math.floor(screen_width * 0.02)}, 221 | metadata_labeled_group, 222 | } 223 | 224 | table.insert(book_metadata_group, extra_metadata_group) 225 | 226 | 227 | 228 | local book_details_group = HorizontalGroup:new{ 229 | align = "top", 230 | HorizontalSpan:new{ width = math.floor(screen_width * 0.05) } 231 | } 232 | 233 | local image = AudiobookshelfApi:getLibraryItemCover(self.book_id) 234 | 235 | if image then 236 | local actual_w, actual_h = image:getWidth(), image:getHeight() 237 | if actual_w > img_width or actual_h > img_height then 238 | local scale_factor = math.min(img_width / actual_w, img_height / actual_h) 239 | actual_w = math.min(math.floor(actual_w * scale_factor)+1, img_width) 240 | actual_h = math.min(math.floor(actual_h * scale_factor)+1, img_height) 241 | image = RenderImage:scaleBlitBuffer(image , actual_w, actual_h, true) 242 | end 243 | table.insert(book_details_group, ImageWidget:new{ 244 | image = image, 245 | width = actual_w, 246 | height = actual_h 247 | }) 248 | end 249 | 250 | table.insert( 251 | book_details_group, 252 | HorizontalSpan:new{ width = math.floor(screen_width * 0.05) } 253 | ) 254 | table.insert(book_details_group, book_metadata_group) 255 | 256 | return book_details_group 257 | end 258 | 259 | function BookDetailsWidget:genHeader(title) 260 | local width, height = Screen:getWidth(), Size.item.height_default 261 | 262 | local header_title = TextWidget:new{ 263 | text = title, 264 | face = self.medium_font, 265 | fgcolor = Blitbuffer.COLOR_GRAY_9 266 | } 267 | local padding_span = HorizontalSpan:new{ width = self.padding } 268 | local line_width = (width - header_title:getSize().w) / 2 - self.padding * 2 269 | local line_container = LeftContainer:new{ 270 | dimen = Geom:new{ w = line_width, h = height }, 271 | LineWidget:new{ 272 | background = Blitbuffer.COLOR_LIGHT_GRAY, 273 | dimen = Geom:new{ 274 | w = line_width, 275 | h = Size.line.thick, 276 | } 277 | } 278 | } 279 | 280 | local span_top, span_bottom 281 | if Screen:getScreenMode() == "landscape" then 282 | span_top = VerticalSpan:new{ width = Size.span.horizontal_default } 283 | span_bottom = VerticalSpan:new{ width = Size.span.horizontal_default } 284 | else 285 | span_top = VerticalSpan:new{ width = Size.item.height_default } 286 | span_bottom = VerticalSpan:new{ width = Size.span.vertical_large } 287 | end 288 | 289 | return VerticalGroup:new{ 290 | span_top, 291 | HorizontalGroup:new{ 292 | align = "center", 293 | padding_span, 294 | line_container, 295 | padding_span, 296 | header_title, 297 | padding_span, 298 | line_container, 299 | padding_span, 300 | }, 301 | span_bottom, 302 | } 303 | end 304 | 305 | function BookDetailsWidget:genDescriptionContent() 306 | local screen_width = Screen:getWidth() 307 | local screen_height = Screen:getHeight() 308 | 309 | local text = ScrollTextWidget:new{ 310 | text = self:stripBasicHTMLTags(self.book_info.media.metadata.description or ""), 311 | face = self.small_font, 312 | width = screen_width - self.padding * 2, 313 | height = screen_height * 0.2, 314 | dialog = self, 315 | } 316 | return CenterContainer:new{ 317 | dimen = Geom:new{ w = screen_width, h = text:getSize().h }, 318 | text 319 | } 320 | end 321 | 322 | function BookDetailsWidget:stripBasicHTMLTags(text) 323 | return string.gsub(text, '<[^>]*>', '') 324 | end 325 | 326 | return BookDetailsWidget --------------------------------------------------------------------------------