Audiobookshelf
audiobookshelf
Simwoodone (12535)6天前
audiobookshelf,不建议连公费、公益,以免被ban
{
"bookSourceUrl": "audiobookshelf",
"bookSourceName": "Audiobookshelf",
"enabledExplore": true,
"enabled": true,
"bookSourceGroup": "",
"author": "",
"help": true,
"html": "<!DOCTYPE html>\n<html lang=\"zh-CN\" data-page-node-id=\"nXqczgWOEnALVeA3Y1TWln\">\n<head data-page-node-id=\"t5sr0UzkUvNAMoGVKXEHyD\">\n <meta charset=\"UTF-8\" data-page-node-id=\"efEmAqf9T1sHiyoGjT7E0O\">\n <title>Audiobookshelf 有声书库<\/title>\n<\/head>\n<body data-page-node-id=\"wV3Wh9uJ3WhSApmymcVy1B\"><\/body>\n<script>\n \/* =========================================================================\n * Audiobookshelf -> 轻悦时光 书源(听书 \/ type = 1)\n * 自建 ABS 服务专用;所有请求走原生 Http 通道,不依赖 WebView、不依赖 jQuery。\n * ========================================================================= *\/\n var isCookieJar = true;\n\n \/\/ ===================== 配置区(可留空,改用 App 内的“登录”弹窗) =====================\n var CONFIG = {\n \"server\": \"http:\/\/192.168.1.100:13378\", \/\/ ← 你的 ABS 地址,末尾不要带 \/\n \"username\": \"\", \/\/ ← ABS 账号\n \"password\": \"\", \/\/ ← ABS 密码\n \"apikey\": \"\", \/\/ ← 可选:ABS 设置页生成的 API Key(填了可免账号密码)\n \"perPage\": 20 \/\/ 发现页每页条数\n };\n \/\/ ===================================================================================\n\n \/\/ ===================== FlutterJSBridge =====================\n class FlutterJSBridge {\n constructor() { this.init(); }\n init() {\n if (window.flutter_inappwebview) {\n this.isReady = true;\n this.CookieJar();\n } else {\n window.addEventListener('flutterInAppWebViewPlatformReady', () => {\n this.isReady = true;\n this.CookieJar();\n });\n }\n }\n async CookieJar() { try { return await window.flutter_inappwebview.callHandler('CookieJar', isCookieJar); } catch (e) {} }\n async getversion() { try { return await window.flutter_inappwebview.callHandler('version'); } catch (e) { return \"0.0.0\"; } }\n async getbuildNumber() { try { return await window.flutter_inappwebview.callHandler('buildNumber'); } catch (e) { return 0; } }\n async getsourceurl() { try { return await window.flutter_inappwebview.callHandler('getsourceurl'); } catch (e) { return \"\"; } }\n async getsearch() { try { return await window.flutter_inappwebview.callHandler('getsearch'); } catch (e) { return -1; } }\n async getWidth() { try { return await window.flutter_inappwebview.callHandler('getWidth'); } catch (e) { return 0; } }\n async getHeight() { try { return await window.flutter_inappwebview.callHandler('getHeight'); } catch (e) { return 0; } }\n async htmlToText(str) { try { return await window.flutter_inappwebview.callHandler('htmlToText', str); } catch (e) { return \"\"; } }\n async showvideoplay(url, title, key) { try { return await window.flutter_inappwebview.callHandler('showvideoplay', url, title, key); } catch (e) { return \"\"; } }\n async refreshContent(bookurl) { try { return await window.flutter_inappwebview.callHandler('refreshContent', bookurl); } catch (e) { return \"\"; } }\n async refreshExplore() { try { return await window.flutter_inappwebview.callHandler('refreshExplore'); } catch (e) { return \"\"; } }\n async setHtml(str) { try { return await window.flutter_inappwebview.callHandler('setHtml', str); } catch (e) { return \"\"; } }\n async getDeviceid() { try { return await window.flutter_inappwebview.callHandler('id'); } catch (e) { return \"\"; } }\n async log(str) { try { return await window.flutter_inappwebview.callHandler('log', String(str)); } catch (e) { return false; } }\n async text(type, str) { try { return await window.flutter_inappwebview.callHandler('text', type, str); } catch (e) { return false; } }\n async showToast(str) { try { return await window.flutter_inappwebview.callHandler('showToast', String(str)); } catch (e) { return false; } }\n async showLongToast(str) { try { return await window.flutter_inappwebview.callHandler('showLongToast', String(str)); } catch (e) { return false; } }\n async getWebViewUA() { try { return await window.flutter_inappwebview.callHandler('getWebViewUA'); } catch (e) { return \"\"; } }\n async openurl(url) { try { return await window.flutter_inappwebview.callHandler('openurl', url, \"\"); } catch (e) { return false; } }\n async openurlwithMimeType(url, mimeType) { try { return await window.flutter_inappwebview.callHandler('openurl', url, mimeType); } catch (e) { return false; } }\n async webview(url, js, html, body, header) { try { return await window.flutter_inappwebview.callHandler('webview', url, js, html, body, header, \"\", \"\"); } catch (e) { return \"\"; } }\n async webViewGetOverrideUrl(url, js, html, body, header, overrideUrlRegex) { try { return await window.flutter_inappwebview.callHandler('webview', url, js, html, body, header, overrideUrlRegex, \"\"); } catch (e) { return \"\"; } }\n async webViewGetSource(url, js, html, body, header, urlregex) { try { return await window.flutter_inappwebview.callHandler('webview', url, js, html, body, header, \"\", urlregex); } catch (e) { return \"\"; } }\n async webViewGetAjax(url, html, body, header, ajaxregex) { try { return await window.flutter_inappwebview.callHandler('webviewajax', url, html, body, header, ajaxregex); } catch (e) { return \"\"; } }\n async startBrowser(url, title, header) { try { return await window.flutter_inappwebview.callHandler('startBrowser', url, title, header); } catch (e) { return \"\"; } }\n async startBrowserWithShouldOverrideUrlLoading(url, title, header) { try { return await window.flutter_inappwebview.callHandler('startBrowserWithShouldOverrideUrlLoading', url, title, header); } catch (e) { return \"\"; } }\n async startBrowserDp(url, title) { try { return await window.flutter_inappwebview.callHandler('startBrowserDp', url, title); } catch (e) { return \"\"; } }\n async startBrowserDpwithHtml(url, html, title) { try { return await window.flutter_inappwebview.callHandler('startBrowserDpwithHtml', url, html, title); } catch (e) { return \"\"; } }\n async back() { try { return await window.flutter_inappwebview.callHandler('back'); } catch (e) { return false; } }\n async utf8ToGbkUrlEncoded(str) { try { return await window.flutter_inappwebview.callHandler('utf8ToGbkUrlEncoded', str); } catch (e) { return \"\"; } }\n async getVerificationCode(str, header) { try { return await window.flutter_inappwebview.callHandler('getVerificationCode', str, header); } catch (e) { return \"\"; } }\n async addbook(bookUrl) { try { return await window.flutter_inappwebview.callHandler('addbook', bookUrl); } catch (e) { return \"\"; } }\n async searchbook(key) { try { return await window.flutter_inappwebview.callHandler('searchbook', key); } catch (e) { return \"\"; } }\n async getReadBook() { try { return await window.flutter_inappwebview.callHandler('getReadBook'); } catch (e) { return null; } }\n async base64encode(str) { try { return await window.flutter_inappwebview.callHandler('base64encode', str); } catch (e) { return \"\"; } }\n async base64decode(str) { try { return await window.flutter_inappwebview.callHandler('base64decode', str); } catch (e) { return \"\"; } }\n }\n\n \/\/ ===================== Http =====================\n class Http {\n async Get(url, headers, followRedirects) {\n try { return await window.flutter_inappwebview.callHandler('http', \"get\", url, \"\", JSON.stringify(headers), followRedirects, \"\"); }\n catch (e) { return null; }\n }\n async Head(url, headers, followRedirects) {\n try { return await window.flutter_inappwebview.callHandler('http', \"head\", url, \"\", JSON.stringify(headers), followRedirects, \"\"); }\n catch (e) { return null; }\n }\n async Post(url, headers, body, contenttype, followRedirects) {\n try { return await window.flutter_inappwebview.callHandler('http', \"post\", url, body, JSON.stringify(headers), followRedirects, contenttype); }\n catch (e) { return null; }\n }\n }\n\n \/\/ ===================== Cache \/ Cookie =====================\n class Cache {\n async get(key) { try { return await window.flutter_inappwebview.callHandler('cache.get', key); } catch (e) { return null; } }\n async set(key, value) { try { return await window.flutter_inappwebview.callHandler('cache.set', key, value); } catch (e) { return null; } }\n async remove(key) { try { return await window.flutter_inappwebview.callHandler('cache.remove', key); } catch (e) { return null; } }\n async getLoginInfo() { return await this.get(\"LoginInfo\"); }\n async putLoginInfo(info) { return await this.set(\"LoginInfo\", info); }\n }\n class Cookie {\n async get(url) { try { return await window.flutter_inappwebview.callHandler('cookie.get', url); } catch (e) { return null; } }\n async set(url, value) { try { return await window.flutter_inappwebview.callHandler('cookie.set', url, value); } catch (e) { return null; } }\n }\n<\/script>\n\n<script>\n const flutterBridge = new FlutterJSBridge();\n const http = new Http();\n const cache = new Cache();\n const cookie = new Cookie();\n\n \/\/ ===================== 运行时状态 =====================\n var SERVER = \"\";\n var TOKEN = \"\";\n var itemCache = {}; \/\/ 详情接口结果的内存缓存,避免详情\/目录重复请求\n\n \/\/ ===================== 工具函数 =====================\n function normServer(s) {\n s = String(s == null ? \"\" : s).trim().replace(\/\\s+\/g, \"\");\n if (!s) return \"\";\n s = s.replace(\/\\\/+$\/, \"\");\n if (!\/^https?:\\\/\\\/\/i.test(s)) s = \"http:\/\/\" + s;\n return s;\n }\n function respText(resp) {\n if (resp == null) return \"\";\n if (typeof resp === \"string\") return resp;\n if (resp.data !== undefined && resp.data !== null) {\n return (typeof resp.data === \"string\") ? resp.data : JSON.stringify(resp.data);\n }\n return \"\";\n }\n function withToken(u) {\n u = String(u).replace(\/([?&])token=[^&]*\/g, \"$1\").replace(\/[?&]+$\/, \"\").replace(\/\\?&\/, \"?\");\n if (!TOKEN) return u;\n return u + (u.indexOf(\"?\") >= 0 ? \"&\" : \"?\") + \"token=\" + encodeURIComponent(TOKEN);\n }\n function esc(s) {\n return String(s == null ? \"\" : s).replace(\/&\/g, \"&\").replace(\/<\/g, \"<\").replace(\/>\/g, \">\");\n }\n function stripTags(s) {\n s = String(s == null ? \"\" : s);\n s = s.replace(\/<script[\\s\\S]*?<\\\/script>\/gi, \"\").replace(\/<style[\\s\\S]*?<\\\/style>\/gi, \"\");\n return s;\n }\n function dur(sec) {\n sec = Math.round(Number(sec) || 0);\n if (!sec) return \"\";\n var h = Math.floor(sec \/ 3600), m = Math.round((sec % 3600) \/ 60);\n if (h > 0) return h + \"小时\" + (m > 0 ? m + \"分\" : \"\");\n return m + \"分钟\";\n }\n function stripExt(s) {\n s = String(s == null ? \"\" : s).trim();\n return s.replace(\/\\.[a-z0-9]{2,4}$\/i, \"\");\n }\n function pad2(n) { n = Number(n) || 0; return n < 10 ? \"0\" + n : \"\" + n; }\n function hhmm(sec) {\n sec = Math.round(Number(sec) || 0);\n return pad2(Math.floor(sec \/ 3600)) + \":\" + pad2(Math.floor((sec % 3600) \/ 60)) + \":\" + pad2(sec % 60);\n }\n\n \/\/ ===================== 配置 \/ 鉴权 =====================\n \/\/ 优先级:App 登录弹窗(LoginInfo) > 源码顶部 CONFIG\n async function readConfig() {\n var cfg = {\n server: normServer(CONFIG.server),\n username: String(CONFIG.username || \"\").trim(),\n password: String(CONFIG.password || \"\"),\n apikey: String(CONFIG.apikey || \"\").trim()\n };\n try {\n var raw = await cache.getLoginInfo();\n if (raw) {\n var o = (typeof raw === \"string\") ? JSON.parse(raw) : raw;\n if (o && typeof o === \"object\") {\n if (o[\"服务器地址\"]) cfg.server = normServer(o[\"服务器地址\"]);\n if (o[\"用户名\"] !== undefined && String(o[\"用户名\"]).trim() !== \"\") cfg.username = String(o[\"用户名\"]).trim();\n if (o[\"密码\"] !== undefined && String(o[\"密码\"]) !== \"\") cfg.password = String(o[\"密码\"]);\n if (o[\"Token或APIKey\"] !== undefined && String(o[\"Token或APIKey\"]).trim() !== \"\") cfg.apikey = String(o[\"Token或APIKey\"]).trim();\n }\n }\n } catch (e) { await flutterBridge.log(\"ABS readConfig: \" + e.message); }\n return cfg;\n }\n\n async function ensureAuth() {\n var cfg = await readConfig();\n if (!cfg.server) { SERVER = \"\"; TOKEN = \"\"; return false; }\n SERVER = cfg.server;\n\n \/\/ 1) 直接给了 API Key \/ 长期 token\n if (cfg.apikey) { TOKEN = cfg.apikey; return true; }\n\n \/\/ 2) 缓存里已有该服务器的 token(\/login 返回的 token 无过期时间)\n var cached = await cache.get(\"abs_token\");\n if (cached) {\n var s = String(cached), i = s.indexOf(\"|\");\n if (i > 0 && s.substring(0, i) === SERVER) { TOKEN = s.substring(i + 1); return true; }\n }\n\n \/\/ 3) 账号密码登录\n if (!cfg.username || !cfg.password) { TOKEN = \"\"; return false; }\n var body = \"{\\\"username\\\":\" + JSON.stringify(cfg.username) + \",\\\"password\\\":\" + JSON.stringify(cfg.password) + \"}\";\n var resp = await http.Post(SERVER + \"\/login\", JSON.stringify({ \"Content-Type\": \"application\/json\" }), body, \"application\/json\", true);\n var txt = respText(resp);\n if (!txt) { TOKEN = \"\"; await flutterBridge.log(\"ABS login: 无响应(检查地址\/网络)\"); return false; }\n try {\n var j = JSON.parse(txt);\n if (j && j.user && j.user.token) {\n TOKEN = j.user.token;\n await cache.set(\"abs_token\", SERVER + \"|\" + TOKEN);\n return true;\n }\n await flutterBridge.log(\"ABS login 失败: \" + txt.substring(0, 200));\n } catch (e) {\n await flutterBridge.log(\"ABS login 解析失败: \" + txt.substring(0, 200));\n }\n TOKEN = \"\";\n return false;\n }\n\n \/\/ ===================== 通用请求 =====================\n async function getJson(url) {\n var resp = await http.Get(withToken(url), JSON.stringify({}), true);\n var txt = respText(resp);\n if (!txt) return null;\n try { return JSON.parse(txt); } catch (e) {\n await flutterBridge.log(\"ABS JSON 解析失败(\" + url + \"): \" + txt.substring(0, 200));\n return null;\n }\n }\n async function apiGet(path) { return await getJson(SERVER + path); }\n\n \/\/ 详情(expanded=1):一次拿到 metadata \/ tracks \/ chapters \/ audioFiles\n async function getExpanded(url) {\n var clean = String(url).replace(\/([?&])token=[^&]*\/g, \"$1\").replace(\/[?&]+$\/, \"\").replace(\/\\?&\/, \"?\");\n if (itemCache[clean]) return itemCache[clean];\n var u = clean + (clean.indexOf(\"?\") >= 0 ? \"&\" : \"?\") + \"expanded=1\";\n var j = await getJson(u);\n if (j && j.id) itemCache[clean] = j;\n return j;\n }\n\n \/\/ ===================== 字段映射 =====================\n function inoOf(t) {\n if (!t) return \"\";\n var m = String(t.contentUrl || \"\").match(\/\\\/file\\\/([^\\\/?]+)\/);\n if (m) return m[1];\n if (t.ino !== undefined && t.ino !== null) return String(t.ino);\n return \"\";\n }\n\n function itemToBook(it) {\n var id = it && it.id ? String(it.id) : \"\";\n var m = (it && it.media) ? it.media : {};\n var md = m.metadata || {};\n var title = md.title || \"未命名\";\n if (md.subtitle) title += \":\" + md.subtitle;\n\n var author = String(md.authorName || \"\").trim();\n if (md.narratorName) author = author ? (author + \" \/ 主播:\" + md.narratorName) : (\"主播:\" + md.narratorName);\n\n var kind = (md.genres && md.genres.length) ? md.genres.join(\"、\") : \"\";\n if (!kind && md.seriesName) kind = md.seriesName;\n if (!kind) kind = \"有声书\";\n\n var latest = \"\";\n var n = Number(m.numTracks || m.numAudioFiles || 0);\n if (n > 1) latest = \"共 \" + n + \" 集\";\n\n return {\n \"bookUrl\": SERVER + \"\/api\/items\/\" + id,\n \"name\": title,\n \"author\": author || \"未知\",\n \"kind\": kind,\n \"coverUrl\": SERVER + \"\/api\/items\/\" + id + \"\/cover?token=\" + encodeURIComponent(TOKEN),\n \"intro\": \"\",\n \"tocUrl\": SERVER + \"\/api\/items\/\" + id,\n \"wordCount\": dur(m.duration),\n \"type\": 1,\n \"latestChapterTitle\": latest\n };\n }\n\n function mapItems(arr) {\n var out = [];\n if (!arr || !arr.length) return out;\n for (var i = 0; i < arr.length; i++) {\n var it = arr[i];\n if (!it || !it.id) continue;\n out.push(itemToBook(it));\n }\n return out;\n }\n\n function buildIntro(md, m, id) {\n var rows = [];\n function add(k, v) { if (v !== undefined && v !== null && String(v) !== \"\") rows.push(\"<b>\" + k + \"<\/b>:\" + esc(v)); }\n add(\"作者\", md.authorName);\n add(\"主播\", md.narratorName);\n add(\"系列\", md.seriesName);\n add(\"分类\", (md.genres && md.genres.length) ? md.genres.join(\"、\") : \"\");\n add(\"出版\", md.publisher);\n add(\"年份\", md.publishedYear);\n add(\"语言\", md.language);\n add(\"时长\", dur(m.duration));\n add(\"集数\", (Number(m.numTracks || m.numAudioFiles || 0) > 1) ? (m.numTracks || m.numAudioFiles) + \" 集\" : \"\");\n add(\"标签\", (m.tags && m.tags.length) ? m.tags.join(\"、\") : \"\");\n\n var html = \"@html:<div style='line-height:1.7'>\" + rows.join(\"<br\/>\") + \"<\/div>\";\n if (md.description) html += \"<hr\/>\" + stripTags(md.description);\n html += \"<hr\/><button>在 Audiobookshelf 中打开@onclick:flutterBridge.startBrowser('\" + SERVER + \"\/item\/\" + id + \"','Audiobookshelf','{}')<\/button>\";\n return html;\n }\n\n function chapterTitleAt(chs, offset) {\n if (!chs || !chs.length) return \"\";\n var o = Number(offset) || 0;\n for (var i = 0; i < chs.length; i++) {\n var st = Number(chs[i].start) || 0;\n var en = Number(chs[i].end);\n if (!en) en = st + 1e9;\n if (o >= st - 1 && o < en + 1) return chs[i].title || \"\";\n }\n return \"\";\n }\n\n function mkChapter(name, chapterId, index) {\n return {\n \"name\": String(name || \"\").trim() || (\"第 \" + (index + 1) + \" 集\"),\n \"chapterId\": chapterId,\n \"index\": index,\n \"isPay\": false,\n \"isVip\": false,\n \"isVolume\": false,\n \"tag\": \"\"\n };\n }\n\n function tracksOf(m, itemId) {\n if (m && m.tracks && m.tracks.length) return m.tracks;\n var af = (m && m.audioFiles) ? m.audioFiles : [];\n var out = [], off = 0;\n for (var i = 0; i < af.length; i++) {\n var t = af[i];\n out.push({\n index: t.index,\n ino: t.ino,\n startOffset: off,\n duration: t.duration,\n title: (t.metadata && t.metadata.filename) || \"\",\n contentUrl: \"\/api\/items\/\" + itemId + \"\/file\/\" + t.ino\n });\n off += Number(t.duration) || 0;\n }\n return out;\n }\n\n \/\/ ===================== 核心函数 =====================\n \/\/ 1) 搜索\n async function search(key, page, env) {\n if (page > 1) return \"[]\";\n if (env === \"searchone\") {\n var st = await flutterBridge.getsearch();\n if (st !== -1 && st !== 1) return \"[]\"; \/\/ 只提供听书\n }\n if (!(await ensureAuth())) { await flutterBridge.showToast(\"请先配置 Audiobookshelf 服务器与账号(书源详情 → 登录)\"); return \"[]\"; }\n key = String(key || \"\").trim();\n if (!key) return \"[]\";\n\n var libsResp = await apiGet(\"\/api\/libraries\");\n var libs = (libsResp && libsResp.libraries) ? libsResp.libraries : [];\n if (!libs.length) { await flutterBridge.log(\"ABS search: 无法获取书库列表\"); return \"[]\"; }\n\n var books = [], seen = {};\n for (var i = 0; i < libs.length; i++) {\n if (libs[i].mediaType !== \"book\") continue;\n var r = await apiGet(\"\/api\/libraries\/\" + libs[i].id + \"\/search?q=\" + encodeURIComponent(key) + \"&limit=\" + CONFIG.perPage);\n if (!r) continue;\n var arr = r.book;\n if (!arr && Object.prototype.toString.call(r) === \"[object Array]\") arr = r;\n if (!arr || !arr.length) continue;\n for (var j = 0; j < arr.length; j++) {\n var it = arr[j].libraryItem || arr[j];\n if (!it || !it.id || seen[it.id]) continue;\n seen[it.id] = 1;\n books.push(itemToBook(it));\n }\n }\n return JSON.stringify(books);\n }\n\n \/\/ 2) 详情\n async function info(bookurl) {\n if (!(await ensureAuth())) { await flutterBridge.showToast(\"请先配置 Audiobookshelf 服务器与账号(书源详情 → 登录)\"); return JSON.stringify({ \"bookUrl\": bookurl }); }\n var data = await getExpanded(bookurl);\n if (!data || !data.id) return JSON.stringify({ \"bookUrl\": bookurl });\n var m = data.media || {}, md = m.metadata || {};\n var b = itemToBook(data);\n b.bookUrl = bookurl; \/\/ 必须原样返回\n b.tocUrl = bookurl;\n b.intro = buildIntro(md, m, data.id);\n var chs = m.chapters || [];\n if (chs.length) b.latestChapterTitle = \"共 \" + chs.length + \" 章\" + (Number(m.duration) ? \" · \" + dur(m.duration) : \"\");\n return JSON.stringify(b);\n }\n\n \/\/ 3) 目录\n async function chapter(tocUrl, bookurl) {\n if (!(await ensureAuth())) return \"[]\";\n var data = await getExpanded(tocUrl);\n if (!data || !data.id) return \"[]\";\n var m = data.media || {}, md = m.metadata || {};\n var itemId = String(data.id);\n var tracks = tracksOf(m, itemId);\n var chs = m.chapters || [];\n var list = [];\n\n try {\n if (tracks.length <= 1 && chs.length > 1) {\n \/\/ 单文件多章节(常见 m4b):用章节表切分,#t=start,end 起播点\n var ino0 = tracks.length === 1 ? inoOf(tracks[0]) : inoOf((m.audioFiles || [])[0]);\n if (!ino0) return \"[]\";\n list.push(mkChapter(\"▶ 整本连播\", \"abs:\" + itemId + \":\" + ino0 + \":0:0\", 0));\n for (var i = 0; i < chs.length; i++) {\n var st = Math.max(0, Math.floor(Number(chs[i].start) || 0));\n var en = Math.floor(Number(chs[i].end) || 0);\n if (en <= st) continue;\n list.push(mkChapter((i + 1) + \". \" + (chs[i].title || \"章节\"), \"abs:\" + itemId + \":\" + ino0 + \":\" + st + \":\" + en, list.length));\n }\n } else if (tracks.length) {\n \/\/ 多文件:一个音频文件 = 一集\n var lastTitle = \"\";\n for (var k = 0; k < tracks.length; k++) {\n var t = tracks[k];\n var ino = inoOf(t);\n if (!ino) continue;\n var stem = stripExt(t.title || ((t.metadata && t.metadata.filename) || \"\"));\n var cname = chapterTitleAt(chs, t.startOffset);\n if (!cname) cname = stem || md.title || \"\";\n \/\/ 同一章节跨多个音频文件时,补上文件名避免重名\n if (cname === lastTitle && stem) cname = cname + \"(\" + stem + \")\";\n lastTitle = chapterTitleAt(chs, t.startOffset) || \"\";\n list.push(mkChapter((k + 1) + \". \" + cname, \"abs:\" + itemId + \":\" + ino + \":0:0\", list.length));\n }\n }\n } catch (e) {\n await flutterBridge.log(\"ABS chapter err: \" + e.message);\n }\n return JSON.stringify(list);\n }\n\n \/\/ 4) 正文 = 音频直链(听书:直接返回音频 URL,App 自动调起播放器)\n async function content(url, bookurl) {\n if (!(await ensureAuth())) return \"\";\n var mm = String(url || \"\").match(\/^abs:([^:]+):([^:]+):(-?\\d+):(-?\\d+)$\/);\n if (!mm) return \"\";\n var itemId = mm[1], ino = mm[2], st = parseInt(mm[3], 10), en = parseInt(mm[4], 10);\n var u = SERVER + \"\/api\/items\/\" + itemId + \"\/file\/\" + ino + \"?token=\" + encodeURIComponent(TOKEN);\n if (en > st) u += \"#t=\" + st + \",\" + en;\n return u;\n }\n\n \/\/ 5) 发现\n async function getfinds() {\n if (!(await ensureAuth())) {\n return JSON.stringify([{ \"title\": \"⚠️ 未配置服务器:书源详情 → 登录,填写 ABS 地址与账号\", \"url\": \"\", \"type\": 1, \"width\": 3 }]);\n }\n var finds = [{ \"title\": \"▶️ 继续收听\", \"url\": \"C\/{{page}}\", \"type\": 0 }];\n\n var r = await apiGet(\"\/api\/libraries\");\n var libs = (r && r.libraries) ? r.libraries : [];\n var bookLibs = [];\n for (var i = 0; i < libs.length; i++) if (libs[i].mediaType === \"book\") bookLibs.push(libs[i]);\n\n if (!bookLibs.length) {\n finds.push({ \"title\": \"(书库为空或拉取失败,点上方登录检查配置)\", \"url\": \"\", \"type\": 1, \"width\": 3 });\n return JSON.stringify(finds);\n }\n\n for (var j = 0; j < bookLibs.length; j++) {\n var lib = bookLibs[j], multi = bookLibs.length > 1, pre = multi ? \" \" : \"\";\n if (multi) finds.push({ \"title\": \"📚 \" + lib.name, \"url\": \"\", \"type\": 1, \"width\": 3 });\n finds.push({ \"title\": pre + \"🆕 最近添加\", \"url\": \"I\/\" + lib.id + \"\/addedAt\/1\/{{page}}\", \"type\": 0 });\n finds.push({ \"title\": pre + \"🔤 书名排序\", \"url\": \"I\/\" + lib.id + \"\/media.metadata.title\/0\/{{page}}\", \"type\": 0 });\n finds.push({ \"title\": pre + \"✍️ 按作者\", \"url\": \"I\/\" + lib.id + \"\/media.metadata.authorName\/0\/{{page}}\", \"type\": 0 });\n finds.push({ \"title\": pre + \"⏱️ 按时长\", \"url\": \"I\/\" + lib.id + \"\/media.duration\/1\/{{page}}\", \"type\": 0 });\n finds.push({ \"title\": pre + \"📅 按出版年\", \"url\": \"I\/\" + lib.id + \"\/media.metadata.publishedYear\/1\/{{page}}\", \"type\": 0 });\n finds.push({ \"title\": pre + \"🎲 随机推荐\", \"url\": \"I\/\" + lib.id + \"\/random\/0\/{{page}}\", \"type\": 0 });\n }\n return JSON.stringify(finds);\n }\n\n \/\/ 6) 发现分页\n async function find(url, page) {\n if (!(await ensureAuth())) { await flutterBridge.showToast(\"请先配置 Audiobookshelf 服务器与账号(书源详情 → 登录)\"); return \"[]\"; }\n var u = String(url || \"\");\n if (u.indexOf(\"page\") === -1) return \"[]\";\n var parts = u.split(\"\/\");\n page = parseInt(page, 10) || 1;\n\n try {\n if (parts[0] === \"C\") { \/\/ 继续收听\n if (page > 1) return \"[]\";\n var r1 = await apiGet(\"\/api\/me\/items-in-progress?limit=25\");\n var a1 = r1 ? (r1.libraryItems || r1.results || []) : [];\n return JSON.stringify(mapItems(a1));\n }\n if (parts[0] === \"I\") { \/\/ 书库列表\n var libId = parts[1], sort = parts[2], desc = parts[3];\n var p = Math.max(0, page - 1); \/\/ ABS 的 page 从 0 开始\n var path = \"\/api\/libraries\/\" + libId + \"\/items?limit=\" + CONFIG.perPage +\n \"&page=\" + p + \"&sort=\" + encodeURIComponent(sort) + \"&desc=\" + (desc === \"1\" ? \"1\" : \"0\");\n var r2 = await apiGet(path);\n var a2 = (r2 && r2.results) ? r2.results : [];\n return JSON.stringify(mapItems(a2));\n }\n } catch (e) {\n await flutterBridge.log(\"ABS find err: \" + e.message);\n }\n return \"[]\";\n }\n\n \/\/ 7) 登录:App 登录弹窗每次改动都会调用本函数\n async function getloginurl() {\n return JSON.stringify([\n { \"name\": \"服务器地址\", \"type\": \"text\" },\n { \"name\": \"用户名\", \"type\": \"text\" },\n { \"name\": \"密码\", \"type\": \"password\" },\n { \"name\": \"Token或APIKey\", \"type\": \"text\" }\n ]);\n }\n async function login() {\n try {\n await cache.remove(\"abs_token\"); \/\/ 配置变了,清掉旧 token,下次请求重新登录\n itemCache = {};\n } catch (e) {}\n }\n\n \/\/ 8) 帮助\n async function gethelp() {\n return \"@html:<b>🎧 Audiobookshelf 书源<\/b><br\/>\" +\n \"1. 书源详情 → 登录,填「服务器地址 \/ 用户名 \/ 密码」,或直接填 ABS 设置页生成的 API Key。<br\/>\" +\n \"2. 地址写成 <code>http:\/\/内网IP:13378<\/code>,末尾不要带 \/。<br\/>\" +\n \"3. 发现页 → 刷新,即可看到各书库;进入书本后按音频文件分集播放。<br\/>\" +\n \"4. 章节返回的是音频直链(含 token),可长按用 MX Player \/ VLC \/ SenPlayer 等外部播放器打开。<br\/>\" +\n \"5. 单文件 m4b 用 <code>#t=起,止<\/code> 片段定位章节,部分播放器不支持片段定位,此时用「整本连播」。<br\/>\" +\n \"6. 若无法播放,请确认手机能直连 ABS 地址(同一局域网 \/ 已配置内网穿透)。\";\n }\n\n \/\/ ===================== 可选函数 =====================\n async function pay(bookurl, url) {}\n async function filter() { return \"\"; }\n async function findfilter(url) { return \"\"; }\n async function imagedecrypt(url, image) { return []; }\n async function shouldOverrideUrlLoading(url) { return true; }\n async function callbackjs(event, book) { return false; }\n<\/script>\n<\/html>\n",
"login": true,
"customButton": false,
"eventListener": false,
"lastUpdateTime": "1789556685073"
}