Audiobookshelf

audiobookshelf

Simwoodone (12535)6天前

audiobookshelf,不建议连公费、公益,以免被ban
二维码导入(APP尚未完成该功能)
{
    "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, \"&amp;\").replace(\/<\/g, \"&lt;\").replace(\/>\/g, \"&gt;\");\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"
}
广告