Files
mpv-config/scripts/uosc_danmaku/apis/dandanplay.lua
T
2026-03-27 07:06:16 +01:00

736 lines
23 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
local msg = require('mp.msg')
local utils = require("mp.utils")
local function extract_url(url)
local path = url:match("^https?://[^/]+(/[^%?]*)")
return path
end
local function generateXSignature(url, time, appid, app_accept)
local url_path = extract_url(url)
if not url_path then
return nil
end
local dataToHash = string.format("%s%d%s%s", AES.ECB.decrypt(KEY, Base64.decode(appid)),
time, url_path, AES.ECB.decrypt(KEY, Base64.decode(app_accept)))
local hash = Sha256(dataToHash)
local base64Hash = Base64.encode(hex_to_bin(hash))
return base64Hash
end
-- 写入history.json
-- 读取episodeId获取danmaku
function set_episode_id(input, from_menu)
from_menu = from_menu or false
DANMAKU.source = "dandanplay"
for url, source in pairs(DANMAKU.sources) do
if source.from == "api_server" then
if source.fname and file_exists(source.fname) then
os.remove(source.fname)
end
if not source.from_history then
DANMAKU.sources[url] = nil
else
DANMAKU.sources[url]["fname"] = nil
end
end
end
local episodeId = tonumber(input)
write_history(episodeId)
set_danmaku_button()
if options.load_more_danmaku then
fetch_danmaku_all(episodeId, from_menu)
else
fetch_danmaku(episodeId, from_menu)
end
end
-- 回退使用额外的弹幕获取方式
function get_danmaku_fallback(query)
local url = options.fallback_server .. "/?url=" .. query
msg.verbose("尝试获取弹幕:" .. url)
local temp_file = "danmaku-" .. PID .. DANMAKU.count .. ".xml"
local danmaku_xml = utils.join_path(DANMAKU_PATH, temp_file)
DANMAKU.count = DANMAKU.count + 1
local arg = {
"curl",
"-L",
"-s",
"--compressed",
"--user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0",
"--output",
danmaku_xml,
url,
}
call_cmd_async(arg, function(error)
async_running = false
if error then
show_message("HTTP 请求失败,打开控制台查看详情", 5)
msg.error(error)
return
end
if file_exists(danmaku_xml) then
if query:find("iqiyi%.com") ~= nil then
DANMAKU.strict = true
end
save_danmaku_downloaded(query, danmaku_xml)
load_danmaku(true)
end
end)
end
-- 返回弹幕请求参数
function make_danmaku_request_args(method, url, headers, body)
local args = {
"curl",
"-L",
"-X",
method,
"-H",
"Accept: application/json",
"-H",
"User-Agent: " .. options.user_agent,
}
if headers then
for k, v in pairs(headers) do
table.insert(args, '-H')
table.insert(args, string.format('%s: %s', k, v))
end
end
if body then
table.insert(args, '-d')
table.insert(args, utils.format_json(body))
table.insert(args, '-H')
table.insert(args, 'Content-Type: application/json')
end
if url:find("api%.dandanplay%.") then
local time = os.time()
local appid = "UgjRIH45lE1BBLNmir1WKw=="
local app_accept = "SzuWlFZAPRMqeWf9qmfp8dcvYr3hvxuSrIRZuAeEfko="
table.insert(args, '-H')
table.insert(args, string.format('X-AppId: %s', AES.ECB.decrypt(KEY, Base64.decode(appid))))
table.insert(args, '-H')
table.insert(args, string.format('X-Signature: %s', generateXSignature(url, time, appid, app_accept)))
table.insert(args, '-H')
table.insert(args, string.format('X-Timestamp: %s', time))
end
table.insert(args, url)
return args
end
-- 尝试通过解析文件名匹配剧集
local function match_episode(animeTitle, bangumiId, episode_num)
local url = options.api_server .. "/api/v2/bangumi/" .. bangumiId
local args = make_danmaku_request_args("GET", url)
if args == nil then
return
end
call_cmd_async(args, function(error, json)
async_running = false
if error then
show_message("HTTP 请求失败,打开控制台查看详情", 5)
msg.error(error)
return
end
local data = utils.parse_json(json)
if not data or not data.bangumi or not data.bangumi.episodes then
msg.info("无结果")
return
end
for _, episode in ipairs(data.bangumi.episodes) do
local ep_num = tonumber(episode.episodeNumber)
if ep_num and ep_num == tonumber(episode_num) then
DANMAKU.anime = animeTitle
DANMAKU.episode = episode.episodeTitle
set_episode_id(episode.episodeId)
break
end
end
end)
end
local function match_anime()
local animes = {}
local anime_type = "tvseries"
local type_count = 0
local title, season_num, episode_num = parse_title()
if not episode_num then
msg.info("无法解析剧集信息")
return
end
if title:match("OVA") or title:match("OAD") then
anime_type = "ova"
end
local encoded_query = url_encode(title)
local url = options.api_server .. "/api/v2/search/anime"
local params = "keyword=" .. encoded_query
local full_url = url .. "?" .. params
local args = make_danmaku_request_args("GET", full_url)
if not args then return end
call_cmd_async(args, function(error, json)
async_running = false
if error then
show_message("HTTP 请求失败,打开控制台查看详情", 5)
msg.error(error)
return
end
local data = utils.parse_json(json)
if not data or not data.animes then
msg.info("无结果")
return
end
for _, anime in ipairs(data.animes) do
if anime.type == anime_type then
type_count = type_count + 1
table.insert(animes, anime)
end
end
if type_count == 1 then
match_episode(animes[1].animeTitle, animes[1].bangumiId, episode_num)
elseif type_count > 1 and season_num then
local best_match, best_score = nil, -1
local target_title = title
if tonumber(season_num) > 1 then
target_title = title .. "" .. number_to_chinese(season_num) .. ""
end
for _, anime in ipairs(animes) do
if anime.animeTitle:match("第一[季部]") and tonumber(season_num) == 1 then
target_title = title .. " 第一季"
end
local score = jaro_winkler(target_title, anime.animeTitle)
msg.debug(("候选: %s -> 相似度 %.3f"):format(anime.animeTitle, score))
if score > best_score then
best_score = score
best_match = anime
end
end
if best_match and best_score >= 0.75 then
msg.info(("模糊匹配选中: %s (score=%.2f)"):format(best_match.animeTitle, best_score))
match_episode(best_match.animeTitle, best_match.bangumiId, episode_num)
else
msg.info("匹配到多个结果,但相似度不足,请手动搜索")
end
else
msg.info("没有找到合适的匹配结果")
end
end)
end
-- 执行哈希匹配获取弹幕
local function match_file(file_path, file_name, callback)
-- 计算文件哈希
local hash = nil
local file_info = utils.file_info(file_path)
if file_info and file_info.size > 16 * 1024 * 1024 then
local file, error = io.open(normalize(file_path), 'rb')
if file and not error then
local m = MD5.new()
for _ = 1, 16 * 1024 do
local content = file:read(1024)
if not content then
break
end
m:update(content)
end
file:close()
hash = m:finish()
end
end
if hash then msg.info('hash:', hash) end
local title, season_num, episode_num = parse_title()
if title and episode_num then
if season_num then
file_name = title .. " S" .. season_num .. "E" .. episode_num
else
file_name = title .. " E" .. episode_num
end
else
file_name = title
end
local url = options.api_server .. "/api/v2/match"
local args = make_danmaku_request_args("POST", url, {
["Content-Type"] = "application/json"
}, {
fileName = file_name,
fileHash = hash or "",
matchMode = "hashAndFileName"
}
)
if not args then return end
call_cmd_async(args, function(error, json)
async_running = false
if error then
show_message("HTTP 请求失败,打开控制台查看详情", 5)
callback(error)
return
end
local data = utils.parse_json(json)
if not data or not data.isMatched or #data.matches > 1 then
callback("没有匹配的剧集")
return
end
DANMAKU.anime = data.matches[1].animeTitle
DANMAKU.episode = data.matches[1].episodeTitle
-- 获取并加载弹幕数据
set_episode_id(data.matches[1].episodeId)
end)
end
-- 异步获取弹幕数据
function fetch_danmaku_data(args, callback)
call_cmd_async(args, function(error, json)
async_running = false
if error then
show_message("获取数据失败", 3)
msg.error("HTTP 请求失败:" .. error)
return
end
local data = utils.parse_json(json)
callback(data)
end)
end
-- 保存弹幕数据
function save_danmaku_data(comments, query, danmaku_source)
local temp_file = "danmaku-" .. PID .. DANMAKU.count .. ".json"
local danmaku_file = utils.join_path(DANMAKU_PATH, temp_file)
DANMAKU.count = DANMAKU.count + 1
local success = save_danmaku_json(comments, danmaku_file)
if success then
if DANMAKU.sources[query] ~= nil then
if DANMAKU.sources[query].fname and file_exists(DANMAKU.sources[query].fname) then
os.remove(DANMAKU.sources[query].fname)
end
DANMAKU.sources[query]["fname"] = danmaku_file
else
DANMAKU.sources[query] = {from = danmaku_source, fname = danmaku_file}
end
end
end
function save_danmaku_downloaded(url, downloaded_file)
if DANMAKU.sources[url] ~= nil then
if DANMAKU.sources[url].fname and file_exists(DANMAKU.sources[url].fname) then
os.remove(DANMAKU.sources[url].fname)
end
DANMAKU.sources[url]["fname"] = downloaded_file
else
DANMAKU.sources[url] = {from = "user_custom", fname = downloaded_file}
end
end
-- 处理弹幕数据
function handle_danmaku_data(query, data, from_menu)
local comments = data["comments"]
local count = data["count"]
-- 如果没有数据,进行重试
if count == 0 then
show_message("服务器无缓存数据,再次尝试请求", 30)
msg.verbose("服务器无缓存数据,再次尝试请求")
-- 等待 2 秒后重试
local start = os.time()
while os.time() - start < 2 do
-- 空循环,等待 2 秒
end
-- 重新发起请求
local url = options.api_server .. "/api/v2/extcomment?url=" .. url_encode(query)
local args = make_danmaku_request_args("GET", url)
if args == nil then
return
end
fetch_danmaku_data(args, function(retry_data)
if not retry_data or not retry_data["comments"] or retry_data["count"] == 0 then
get_danmaku_fallback(query)
return
end
save_danmaku_data(retry_data["comments"], query, "user_custom")
load_danmaku(from_menu)
end)
else
save_danmaku_data(comments, query, "user_custom")
load_danmaku(from_menu)
end
end
-- 处理第三方弹幕数据
function handle_related_danmaku(index, relateds, related, shift, callback)
local url = options.api_server .. "/api/v2/extcomment?url=" .. url_encode(related["url"])
show_message(string.format("正在从第三方库装填弹幕 [%d/%d]", index, #relateds), 30)
msg.verbose("正在从第三方库装填弹幕:" .. url)
local args = make_danmaku_request_args("GET", url)
if args == nil then
return
end
fetch_danmaku_data(args, function(data)
local comments = {}
if data and data["comments"] then
if data["count"] == 0 then
-- 如果没有数据,稍等 2 秒重试
local start = os.time()
while os.time() - start < 2 do
-- 空循环,等待 2 秒
end
fetch_danmaku_data(args, function(data)
for _, comment in ipairs(data["comments"]) do
comment["shift"] = shift
table.insert(comments, comment)
end
callback(comments)
end)
else
for _, comment in ipairs(data["comments"]) do
comment["shift"] = shift
table.insert(comments, comment)
end
callback(comments)
end
else
show_message("无数据", 3)
msg.info("无数据")
callback(comments)
end
end)
end
-- 处理dandan库的弹幕数据
function handle_main_danmaku(url, from_menu)
show_message("正在从弹弹Play库装填弹幕", 30)
msg.verbose("尝试获取弹幕:" .. url)
local args = make_danmaku_request_args("GET", url)
if args == nil then
return
end
fetch_danmaku_data(args, function(data)
if not data or not data["comments"] then
show_message("无数据", 3)
msg.info("无数据")
return
end
local comments = data["comments"]
local count = data["count"]
if count == 0 then
if DANMAKU.sources[url] == nil then
DANMAKU.sources[url] = {from = "api_server"}
end
load_danmaku(from_menu)
return
end
save_danmaku_data(comments, url, "api_server")
load_danmaku(from_menu)
end)
end
-- 处理获取到的数据
function handle_fetched_danmaku(data, url, from_menu)
if data and data["comments"] then
if data["count"] == 0 then
if DANMAKU.sources[url] == nil then
DANMAKU.sources[url] = {from = "api_server"}
end
show_message("该集弹幕内容为空,结束加载", 3)
msg.verbose("该集弹幕内容为空,结束加载")
return
end
save_danmaku_data(data["comments"], url, "api_server")
load_danmaku(from_menu)
else
show_message("无数据", 3)
msg.info("无数据")
end
end
-- 匹配弹幕库 comment, 仅匹配dandan本身弹幕库
-- 通过danmaku apiurl+id获取弹幕
function fetch_danmaku(episodeId, from_menu)
local url = options.api_server .. "/api/v2/comment/" .. episodeId .. "?withRelated=true&chConvert=0"
show_message("弹幕加载中...", 30)
msg.verbose("尝试获取弹幕:" .. url)
local args = make_danmaku_request_args("GET", url)
if args == nil then
return
end
fetch_danmaku_data(args, function(data)
handle_fetched_danmaku(data, url, from_menu)
end)
end
-- 主函数:获取所有相关弹幕
function fetch_danmaku_all(episodeId, from_menu)
local url = options.api_server .. "/api/v2/related/" .. episodeId
show_message("弹幕加载中...", 30)
msg.verbose("尝试获取弹幕:" .. url)
local args = make_danmaku_request_args("GET", url)
if args == nil then
return
end
fetch_danmaku_data(args, function(data)
if not data or not data["relateds"] then
show_message("无数据", 3)
msg.info("无数据")
return
end
-- 处理所有的相关弹幕
local relateds = data["relateds"]
local function process_related(index)
if index > #relateds then
-- 所有相关弹幕加载完成后,开始加载主库弹幕
url = options.api_server .. "/api/v2/comment/" .. episodeId .. "?withRelated=false&chConvert=0"
handle_main_danmaku(url, from_menu)
return
end
local related = relateds[index]
local shift = related["shift"]
-- 处理当前的相关弹幕
handle_related_danmaku(index, relateds, related, shift, function(comments)
if #comments == 0 then
if DANMAKU.sources[related["url"]] == nil then
DANMAKU.sources[related["url"]] = {from = "api_server"}
end
else
save_danmaku_data(comments, related["url"], "api_server")
end
-- 继续处理下一个相关弹幕
process_related(index + 1)
end)
end
-- 从第一个相关库开始请求
process_related(1)
end)
end
-- 从用户添加过的弹幕源添加弹幕
function addon_danmaku(dir, from_menu)
if dir then
local history_json = read_file(HISTORY_PATH)
local history = utils.parse_json(history_json) or {}
if history[dir] and history[dir].extra ~= nil then
return
end
end
for url, source in pairs(DANMAKU.sources) do
if source.from ~= "api_server" then
add_danmaku_source(url, from_menu)
end
end
end
--通过输入源url获取弹幕库
function add_danmaku_source(query, from_menu)
if DANMAKU.sources[query] == nil then
DANMAKU.sources[query] = {from = "user_custom"}
end
from_menu = from_menu or false
if from_menu then
add_source_to_history(query, DANMAKU.sources[query])
end
if is_protocol(query) then
add_danmaku_source_online(query, from_menu)
else
add_danmaku_source_local(query, from_menu)
end
end
function add_danmaku_source_local(query, from_menu)
local path = normalize(query)
if not file_exists(path) then
msg.warn("无效的文件路径")
return
end
if not (string.match(path, "%.xml$") or string.match(path, "%.json$") or string.match(path, "%.ass$")) then
msg.warn("仅支持弹幕文件")
return
end
if DANMAKU.sources[query] ~= nil then
if DANMAKU.sources[query].fname and file_exists(DANMAKU.sources[query].fname) then
os.remove(DANMAKU.sources[query].fname)
end
DANMAKU.sources[query]["from"] = "user_local"
DANMAKU.sources[query]["fname"] = path
else
DANMAKU.sources[query] = {from = "user_local", fname = path}
end
set_danmaku_button()
load_danmaku(from_menu)
end
--通过输入源url获取弹幕库
function add_danmaku_source_online(query, from_menu)
set_danmaku_button()
local url = options.api_server .. "/api/v2/extcomment?url=" .. url_encode(query)
show_message("弹幕加载中...", 30)
msg.verbose("尝试获取弹幕:" .. url)
local args = make_danmaku_request_args("GET", url)
if args == nil then
return
end
fetch_danmaku_data(args, function(data)
if not data or not data["comments"] then
show_message("此源弹幕无法加载", 3)
msg.verbose("此源弹幕无法加载")
return
end
handle_danmaku_data(query, data, from_menu)
end)
end
-- 将弹幕转换为factory可读的json格式
function save_danmaku_json(comments, json_filename)
local temp_file = "danmaku-" .. PID .. ".json"
json_filename = json_filename or utils.join_path(DANMAKU_PATH, temp_file)
local json_file = io.open(json_filename, "w")
if json_file then
json_file:write("[\n")
for _, comment in ipairs(comments) do
local p = comment["p"]
local shift = comment["shift"]
if p then
local fields = split(p, ",")
if shift ~= nil then
fields[1] = tonumber(fields[1]) + tonumber(shift)
end
local c_value = string.format(
"%s,%s,%s,25,,,",
tostring(fields[1]), -- first field of p to first field of c
fields[3], -- third field of p to second field of c
fields[2] -- second field of p to third field of c
)
local m_value = comment["m"]
:gsub("[%z\1-\31]", "")
:gsub("\\", "")
:gsub("\"", "")
-- Write the JSON object as a single line, no spaces or extra formatting
local json_entry = string.format('{"c":"%s","m":"%s"},\n', c_value, m_value)
json_file:write(json_entry)
end
end
json_file:write("]")
json_file:close()
return true
end
return false
end
-- 通过文件前 16M 的 hash 值进行弹幕匹配
function get_danmaku_with_hash(file_name, file_path)
if type(MD5) ~= "table" or not MD5.sum then
msg.warn("MD5 模块不支持 Lua 5.1,回退到文件名匹配")
match_anime()
return
end
if is_protocol(file_path) then
set_danmaku_button()
local temp_file = "temp-" .. PID .. ".mp4"
local arg = {
"curl",
"--connect-timeout",
"10",
"--max-time",
"30",
"--range",
"0-16777215",
"--user-agent",
options.user_agent,
"--output",
utils.join_path(DANMAKU_PATH, temp_file),
"-L",
file_path,
}
if options.proxy ~= "" then
table.insert(arg, '-x')
table.insert(arg, options.proxy)
end
call_cmd_async(arg, function(error)
async_running = false
file_path = utils.join_path(DANMAKU_PATH, temp_file)
match_file(file_path, file_name, function(error)
if error then
msg.error(error)
msg.info("尝试通过解析文件名获取弹幕")
match_anime()
end
end)
end)
else
local dir = get_parent_directory(file_path)
local excluded_path = utils.parse_json(options.excluded_path)
if PLATFORM == "windows" then
for i, path in pairs(excluded_path) do
excluded_path[i] = path:gsub("/", "\\")
end
end
if contains_any(excluded_path, dir) then
match_anime()
return
end
match_file(file_path, file_name, function(error)
if error then
msg.error(error)
msg.info("尝试通过解析文件名获取弹幕")
match_anime()
end
end)
end
end