diff --git a/api/plugins.json b/api/plugins.json
index 20432c8..8fbce00 100644
--- a/api/plugins.json
+++ b/api/plugins.json
@@ -3,8 +3,9 @@
"plugins/LingyeNBird/avatar-fall/avatar-fall.json",
"plugins/Little100/theme-palette/theme-palette.json",
"plugins/Little100/theme-widgets/theme-widgets.json",
+ "plugins/sealantern/podcast-reader/podcast-reader.json",
"plugins/zhuxiaojt/yuanshen-splash/yuanshen-splash.json"
],
- "total": 4,
- "updated_at": "2026-02-25T15:37:21.332Z"
+ "total": 5,
+ "updated_at": "2026-06-14T04:35:57.679Z"
}
diff --git a/plugins/ieshishinjin/podcast-reader/icon.png b/plugins/ieshishinjin/podcast-reader/icon.png
new file mode 100644
index 0000000..a121efc
Binary files /dev/null and b/plugins/ieshishinjin/podcast-reader/icon.png differ
diff --git a/plugins/ieshishinjin/podcast-reader/main.lua b/plugins/ieshishinjin/podcast-reader/main.lua
new file mode 100644
index 0000000..727ca25
--- /dev/null
+++ b/plugins/ieshishinjin/podcast-reader/main.lua
@@ -0,0 +1,564 @@
+-- ============================================================
+-- 播客订阅器 FINAL
+-- ============================================================
+
+local plugin = {}
+local APP_CORE = "pr-core"
+local STORAGE_PODS = "podcast_feeds"
+local STORAGE_EPS = "podcast_episodes"
+local STORAGE_HEARD = "podcast_heard"
+
+local state = { podcasts = {}, episodes = {}, heard = {}, currentPod = nil, currentEp = nil, showAdd = false, playerOpen = true }
+local coreInjected = false
+
+-- ============================================================
+-- 工具
+-- ============================================================
+local function esc(s)
+ if not s then return "" end
+ return s:gsub("&", "&"):gsub("<", "<"):gsub(">", ">"):gsub('"', """)
+end
+local function trim(s) return s and s:match("^%s*(.-)%s*$") or "" end
+local function mkId(s)
+ if not s or #s == 0 then return "id_" .. math.random(100000, 999999) end
+ local id = s:gsub("[^%w%-_]", "_"):lower()
+ return #id > 64 and id:sub(1, 64) or id
+end
+local function fmtDur(s)
+ s = tonumber(s) or 0
+ local h = math.floor(s / 3600); local m = math.floor((s % 3600) / 60); local sec = math.floor(s % 60)
+ if h > 0 then return string.format("%d:%02d:%02d", h, m, sec) end
+ return string.format("%d:%02d", m, sec)
+end
+local function parseDur(s)
+ if not s or #s == 0 then return 0 end
+ local h, m, s2 = s:match("^(%d+):(%d+):(%d+)$")
+ if h then return tonumber(h)*3600+tonumber(m)*60+tonumber(s2) end
+ local m2, s2 = s:match("^(%d+):(%d+)$")
+ if m2 then return tonumber(m2)*60+tonumber(s2) end
+ return tonumber(s) or 0
+end
+local function fmtDate(s)
+ if not s or #s == 0 then return "" end
+ local y, m, d = s:match("(%d+)%-(%d+)%-(%d+)")
+ if y then return string.format("%d-%02d-%02d", tonumber(y), tonumber(m), tonumber(d)) end
+ local months = {Jan = 1, Feb = 2, Mar = 3, Apr = 4, May = 5, Jun = 6, Jul = 7, Aug = 8, Sep = 9, Oct = 10, Nov = 11, Dec = 12}
+ local dd, mmm, yyyy = s:match("(%d+)%s+(%a%a%a)%s+(%d+)")
+ if dd and mmm and months[mmm] and yyyy then return string.format("%d-%02d-%02d", tonumber(yyyy), months[mmm], tonumber(dd)) end
+ return s:sub(1, 10)
+end
+local function parseTime(s)
+ if not s or #s == 0 then return 0 end
+ local y, m, d = s:match("(%d+)%-(%d+)%-(%d+)")
+ if y then return tonumber(y)*10000+tonumber(m)*100+tonumber(d) end
+ local months = {Jan=1,Feb=2,Mar=3,Apr=4,May=5,Jun=6,Jul=7,Aug=8,Sep=9,Oct=10,Nov=11,Dec=12}
+ local dd, mmm, yyyy = s:match("(%d+)%s+(%a%a%a)%s+(%d+)")
+ if dd and mmm and months[mmm] and yyyy then return tonumber(yyyy)*10000+months[mmm]*100+tonumber(dd) end
+ return 0
+end
+local function deepCopy(t)
+ if type(t) ~= "table" then return t end
+ local r = {}; for k, v in pairs(t) do r[k] = deepCopy(v) end
+ return r
+end
+-- JS 字符串安全转义(处理所有破坏 JS 字符串的字符)
+local function jsStr(s)
+ if not s then return "" end
+ s = s:gsub("\\", "\\\\")
+ s = s:gsub("'", "\\'")
+ s = s:gsub('"', '\\"')
+ s = s:gsub("\n", "\\n")
+ s = s:gsub("\r", "\\r")
+ s = s:gsub("\t", "\\t")
+ s = s:gsub("", '')
+ return s
+end
+
+-- ============================================================
+-- XML / RSS
+-- ============================================================
+local function parseXml(xml)
+ if not xml or #xml == 0 then return nil end
+ xml = xml:gsub("<%?xml.-%?>", "")
+ local root, stack, i, len = nil, {}, 1, #xml
+ while i <= len do
+ local ts = xml:find("<", i); if not ts then break end
+ if ts > i and #stack > 0 then
+ local txt = trim(xml:sub(i, ts - 1))
+ if #txt > 0 then table.insert(stack[#stack].children, {type = "text", content = txt}) end
+ end
+ if ts + 1 > len then break end
+ local nc = xml:sub(ts + 1, ts + 1)
+ if nc == "/" then
+ local ce = xml:find(">", ts); if not ce then break end
+ local tn = trim(xml:sub(ts + 2, ce - 1))
+ if #stack > 0 and stack[#stack].tag == tn then table.remove(stack) end; i = ce + 1
+ elseif nc == "?" or nc == "!" then
+ if xml:sub(ts + 2, ts + 4) == "--" then local ce = xml:find("-->", ts); i = (ce or ts) + 3
+ elseif xml:sub(ts + 2, ts + 8) == "[CDATA[" then
+ local ce = xml:find("]]>", ts)
+ if ce and #stack > 0 then table.insert(stack[#stack].children, {type = "text", content = xml:sub(ts + 9, ce - 1)}) end
+ i = (ce or ts) + 3
+ else local ce = xml:find(">", ts); i = (ce or ts) + 1 end
+ else
+ local ce = xml:find(">", ts); if not ce then break end
+ local tc = xml:sub(ts + 1, ce - 1)
+ if tc:match("^%s*$") then i = ce + 1; break end
+ local sc = tc:sub(-1) == "/"
+ if sc then tc = tc:sub(1, -2) end
+ local tn = tc:match("^([%w:_%-]+)"); if not tn then i = ce + 1; break end
+ local attrs = {}
+ for k, v in tc:gmatch('([%w:_%-]+)%s*=%s*"([^"]*)"') do attrs[k] = v end
+ for k, v in tc:gmatch("([%w:_%-]+)%s*=%s*'([^']*)'") do attrs[k] = v end
+ local node = {tag = tn, attrs = attrs, children = {}}
+ if #stack == 0 then root = node else table.insert(stack[#stack].children, node) end
+ if not sc then table.insert(stack, node) end; i = ce + 1
+ end
+ end
+ return root
+end
+local function getText(node, tag)
+ if not node or not node.children then return nil end
+ for _, c in ipairs(node.children) do
+ if c.tag == tag then
+ local t = ""
+ for _, x in ipairs(c.children or {}) do if x.type == "text" then t = t .. (x.content or "") end end
+ return trim(t)
+ end
+ end
+ return nil
+end
+local function getNodes(node, tag)
+ local r = {}
+ if node and node.children then for _, c in ipairs(node.children) do if c.tag == tag then table.insert(r, c) end end end
+ return r
+end
+local function getAttr(node, tag, attr)
+ for _, c in ipairs(node.children or {}) do if c.tag == tag and c.attrs then return c.attrs[attr] end end
+ return nil
+end
+local function parseFeed(xml, url)
+ local p = parseXml(xml); if not p then return nil end
+ local info = {title = url, author = "", artwork = "", desc = "", episodes = {}}
+ if p.tag == "rss" then
+ local chs = getNodes(p, "channel"); if #chs == 0 then return nil end
+ local ch = chs[1]
+ info.title = getText(ch, "title") or url
+ info.author = getAttr(ch, "itunes:author", "content") or getText(ch, "itunes:author") or getText(ch, "author") or ""
+ info.artwork = getAttr(ch, "itunes:image", "href") or getText(ch, "itunes:image") or ((getNodes(ch, "image") or {})[1] and getText((getNodes(ch, "image"))[1], "url")) or ""
+ info.desc = (getText(ch, "description") or ""):gsub("<[^>]+>", " "):gsub("%s+", " "):sub(1, 500)
+ for _, item in ipairs(getNodes(ch, "item")) do
+ local eUrl = ""
+ for _, c in ipairs(item.children or {}) do if c.tag == "enclosure" and c.attrs then eUrl = c.attrs.url or "" end end
+ if eUrl ~= "" then
+ local guid = getText(item, "guid") or eUrl
+ table.insert(info.episodes, {
+ id = mkId(guid), title = getText(item, "title") or "无标题",
+ desc = (getText(item, "description") or ""):gsub("<[^>]+>", " "):gsub("%s+", " "):sub(1, 300),
+ url = eUrl, pubDate = getText(item, "pubDate") or "",
+ duration = parseDur(getAttr(item, "itunes:duration", "content") or getText(item, "itunes:duration") or getText(item, "duration") or ""),
+ artwork = getAttr(item, "itunes:image", "href") or getText(item, "itunes:image") or info.artwork,
+ })
+ end
+ end
+ table.sort(info.episodes, function(a, b) return parseTime(a.pubDate) > parseTime(b.pubDate) end)
+ end
+ return info
+end
+
+-- ============================================================
+-- 存储
+-- ============================================================
+local function loadState()
+ local ok1, p = pcall(sl.storage.get, STORAGE_PODS)
+ local ok2, e = pcall(sl.storage.get, STORAGE_EPS)
+ local ok3, h = pcall(sl.storage.get, STORAGE_HEARD)
+ state.podcasts = (ok1 and p) and deepCopy(p) or {}
+ state.episodes = (ok2 and e) and deepCopy(e) or {}
+ state.heard = (ok3 and h) and deepCopy(h) or {}
+ state.playerOpen = sl.storage.get("show_player") ~= false
+end
+local function saveAll()
+ pcall(function() sl.storage.set(STORAGE_PODS, deepCopy(state.podcasts)) end)
+ pcall(function() sl.storage.set(STORAGE_EPS, deepCopy(state.episodes)) end)
+ pcall(function() sl.storage.set(STORAGE_HEARD, deepCopy(state.heard)) end)
+end
+
+-- ============================================================
+-- 播客管理
+-- ============================================================
+function addPodcast(url)
+ url = trim(url)
+ if not url or #url == 0 then return false, "请输入地址" end
+ if not url:match("^https?://") then return false, "请输入有效地址" end
+ for _, p in ipairs(state.podcasts) do if p.url == url then return false, "已添加过" end end
+ local pod = {id = mkId(url), url = url, title = url, author = "", artwork = "", desc = ""}
+ table.insert(state.podcasts, pod); saveAll()
+ local ok, msg = fetchPod(pod)
+ return true, ok and "已添加" or "添加失败: " .. tostring(msg)
+end
+function removePodcast(id)
+ local np = {}; for _, p in ipairs(state.podcasts) do if p.id ~= id then table.insert(np, p) end end
+ state.podcasts = np
+ local ne = {}; for _, e in ipairs(state.episodes) do if e.podId ~= id then table.insert(ne, e) end end
+ state.episodes = ne; saveAll()
+ if state.currentPod == id then state.currentPod = nil end
+ if state.currentEp then
+ local found = false; for _, e in ipairs(state.episodes) do if e.id == state.currentEp then found = true; break end end
+ if not found then state.currentEp = nil end
+ end
+end
+function fetchPod(pod)
+ local resp, err = sl.http.get(pod.url, {timeout = 20})
+ if not resp then return false, tostring(err) end
+ if resp.status ~= 200 then return false, "HTTP " .. tostring(resp.status) end
+ local info = parseFeed(resp.body, pod.url)
+ if not info then return false, "无法解析 RSS" end
+ pod.title = info.title or pod.title; pod.author = info.author or pod.author
+ pod.artwork = info.artwork or pod.artwork; pod.desc = info.desc or ""
+ local exist = {}; for _, e in ipairs(state.episodes) do if e.podId == pod.id then exist[e.id] = true end end
+ local n = 0
+ for _, ed in ipairs(info.episodes) do
+ if not exist[ed.id] then ed.podId = pod.id; table.insert(state.episodes, 1, ed); n = n + 1 end
+ end
+ saveAll(); return true, n
+end
+function refreshPod(podId)
+ for _, p in ipairs(state.podcasts) do if p.id == podId then return fetchPod(p) end end
+ return false, "未找到"
+end
+function getEps(podId)
+ local r = {}; for _, e in ipairs(state.episodes) do if e.podId == podId then table.insert(r, e) end end
+ table.sort(r, function(a, b) return parseTime(a.pubDate) > parseTime(b.pubDate) end)
+ return r
+end
+function getUnheard(podId)
+ local c = 0; for _, e in ipairs(state.episodes) do if e.podId == podId and not state.heard[e.id] then c = c + 1 end end
+ return c
+end
+function getPodById(podId)
+ for _, p in ipairs(state.podcasts) do if p.id == podId then return p end end
+ return nil
+end
+function getCurrentEpData()
+ if not state.currentEp then return nil end
+ for _, e in ipairs(state.episodes) do if e.id == state.currentEp then return e end end
+ return nil
+end
+
+-- ============================================================
+-- UI 生成
+-- ============================================================
+function genWelcome()
+ return '
'
+ .. '
欢迎使用播客订阅器
'
+ .. '
添加 RSS 订阅地址,自动获取最新剧集
'
+ .. '
'
+end
+function genHeader()
+ local title = state.currentPod and (getPodById(state.currentPod) or {}).title or "播客"
+ local left = ""
+ if state.currentPod then left = '' end
+ left = left .. '' .. esc(title) .. ''
+ local right = ""
+ if not state.currentPod then right = ''
+ else right = '' end
+ return '
' .. left .. right .. '
'
+end
+function genGrid()
+ if #state.podcasts == 0 then return "" end
+ local html = '
'
+ for _, p in ipairs(state.podcasts) do
+ local art = ""
+ if p.artwork and #p.artwork > 0 then art = ''
+ else art = '' end
+ html = html .. '
'
+ .. '
'
+ end
+ return html .. '
'
+end
+function genEpisodeList()
+ if not state.currentPod then return "" end
+ local eps = getEps(state.currentPod); local pod = getPodById(state.currentPod)
+ if #eps == 0 then return '
还没有剧集
' end
+ local html = '
'
+ for _, e in ipairs(eps) do
+ local src = e.artwork or (pod and pod.artwork) or ""
+ local art = ""
+ if src and #src > 0 then art = ''
+ else art = '' end
+ html = html .. '
'
+ .. '' .. art .. ''
+ .. '' .. esc(e.title) .. ''
+ .. '' .. fmtDate(e.pubDate) .. ''
+ .. (e.duration and e.duration > 0 and ' ·' .. fmtDur(e.duration) .. '' or "")
+ .. (e.id == state.currentEp and ' ·播放中' or "")
+ .. '' .. (e.desc and #e.desc > 0 and '' .. esc(e.desc) .. '' or '') .. ''
+ .. '
'
+ end
+ return html .. '
'
+end
+function genContent()
+ local body = ""
+ if #state.podcasts == 0 then body = genWelcome()
+ else
+ body = genHeader()
+ local pod = getPodById(state.currentPod)
+ if state.currentPod and pod then
+ if (pod.author and #pod.author > 0) or (pod.desc and #pod.desc > 0) then
+ body = body .. '
'
+ if pod.author and #pod.author > 0 then body = body .. '' .. esc(pod.author) .. '' end
+ if pod.desc and #pod.desc > 0 then body = body .. '' .. esc(pod.desc) .. '' end
+ body = body .. '
'
+ end
+ end
+ body = body .. '
' .. (state.currentPod and genEpisodeList() or genGrid()) .. '
'
+ end
+ if state.showAdd then
+ body = body .. '
'
+ .. '
添加播客
'
+ .. ''
+ .. '
'
+ .. ''
+ .. '
'
+ end
+ return '
' .. body .. '
'
+end
+
+-- ============================================================
+-- 播放器 UI 更新(注入脚本直接操作 DOM)
+-- ============================================================
+function syncPlayerUI(url)
+ local ep = nil
+ for _, e in ipairs(state.episodes) do if e.url == url or e.id == url then ep = e; break end end
+ if not ep then return end
+ local pod = getPodById(ep.podId) or {}
+ local art = ep.artwork or pod.artwork or ""
+ local artJS = ""
+ if art and #art > 0 then
+ artJS = "ar.innerHTML='';"
+ else
+ artJS = "ar.innerHTML='';"
+ end
+ local js = "try{var d=document;"
+ -- 注意:不设 audio.src!播放由点击事件直接处理,这里只更新 UI
+ js = js .. "var t=d.getElementById('pr-pb-title');if(t)t.textContent='" .. jsStr(ep.title) .. "';"
+ js = js .. "var p=d.getElementById('pr-pb-pod');if(p)p.textContent='" .. jsStr(pod.title or "") .. "';"
+ js = js .. "var u=d.getElementById('pr-pb-total');if(u)u.textContent='" .. (ep.duration and fmtDur(ep.duration) or "0:00") .. "';"
+ js = js .. "var ar=d.getElementById('pr-pb-art');" .. artJS
+ if state.playerOpen ~= false then
+ js = js .. "var b=d.getElementById('pr-player-bar');if(b)b.style.display='flex';"
+ end
+ js = js .. "}catch(e){}"
+ pcall(function() sl.ui.inject_html("pr-pb-upd", "") end)
+end
+
+-- ============================================================
+-- 核心脚本
+-- ============================================================
+function genCoreHtml()
+ return [==[
+
+