Files
mpv-config/scripts/uosc_danmaku/apis/dandanplay.lua
T
2026-04-03 11:33:51 +02:00

613 lines
19 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"
local api_server = options.api_server
for url, source in pairs(DANMAKU.sources) do
if source.from == "api_server" then
if not source.from_history then
DANMAKU.sources[url] = nil
else
DANMAKU.sources[url]["data"] = nil
api_server = source.api_server or options.api_server
end
end
end
local episodeId = tonumber(input)
local main_url = api_server .. "/api/v2/comment/" .. episodeId .. "?withRelated=true&chConvert=0"
add_source_to_history(main_url, { from = "api_server", api_server = api_server })
write_history(episodeId, api_server)
set_danmaku_button()
fetch_danmaku(episodeId, from_menu, api_server)
end
-- 回退使用额外的弹幕获取方式
function get_danmaku_fallback(query)
local url = options.fallback_server .. "/?ac=dm&url=" .. query
msg.verbose("尝试获取弹幕:" .. url)
local args = make_danmaku_request_args("GET", url)
if not args then return end
fetch_danmaku_data(args, function(data)
if not data or not data["comments"] or data["count"] <= 1 then
msg.info("备用服务器无数据或返回格式不正确")
show_message("备用服务器无数据或返回格式不正确", 3)
return
end
save_danmaku_data(data["comments"], query, "user_custom")
load_danmaku(true)
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
if options.proxy ~= "" then
table.insert(args, '-x')
table.insert(args, options.proxy)
end
table.insert(args, url)
return args
end
local function normalize_danmaku_response(d)
if not d then return d end
-- 已经是 comments/count 格式则直接返回
if d.comments or d.count then return d end
if d.danmuku and type(d.danmuku) == "table" then
local out = {}
for _, item in ipairs(d.danmuku) do
-- item 预期为数组,索引: 1=time, 2=pos(right/top/bottom), 3=color(hex), 5=content
local time = tonumber(item[1]) or 0
local pos = item[2] or "right"
local color = item[3] or ""
local content = item[5] or item[4] or ""
local mode = 1
if pos == "right" then
mode = 1
elseif pos == "top" then
mode = 4
elseif pos == "bottom" then
mode = 5
end
local colorDec = 16777215
if type(color) == "number" then
colorDec = color
elseif type(color) == "string" then
colorDec = hex_to_int_color(color)
end
local p = string.format("%.2f,%d,%d", time, mode, colorDec)
table.insert(out, { p = p, m = content })
end
return { comments = out, count = tonumber(d.danum) or #out }
end
return d
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
local animeTitle = tostring(anime.animeTitle or "")
animeTitle = animeTitle:gsub("^%s*(.-)%s*$", "%1")
:gsub("%s*%(.-%)%s*$", "")
:gsub("%s*【.-】.*$", "")
if animeTitle:match("第一[季部]") and tonumber(season_num) == 1 then
target_title = title .. " 第一季"
end
local score = jaro_winkler(target_title, animeTitle)
msg.debug(("候选: %s -> 相似度 %.3f"):format(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 "a1b2c3d4e5f67890abcd1234ef567890",
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 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)
data = normalize_danmaku_response(data)
callback(data)
end)
end
-- 保存弹幕数据
function save_danmaku_data(comments, query, danmaku_source)
local danmaku_list = save_danmaku_to_list(comments)
if DANMAKU.sources[query] ~= nil then
DANMAKU.sources[query]["data"] = danmaku_list
else
DANMAKU.sources[query] = {from = danmaku_source, data = danmaku_list}
end
end
function save_danmaku_downloaded(url, downloaded_file)
local danmaku_list = parse_danmaku_file(downloaded_file)
if file_exists(downloaded_file) then
os.remove(downloaded_file)
end
if DANMAKU.sources[url] ~= nil then
DANMAKU.sources[url]["data"] = danmaku_list
else
DANMAKU.sources[url] = {from = "user_custom", data = danmaku_list}
end
end
-- 处理弹幕数据
function handle_danmaku_data(query, data, from_menu)
-- 如果没有数据,进行重试
if not data or not data["comments"] or data["count"] <= 1 then
show_message("服务器无缓存数据,再次尝试请求", 10)
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"] <= 1 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(data["comments"], query, "user_custom")
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, api_server)
local url = (api_server or 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 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$")) then
msg.warn("仅支持弹幕文件")
return
end
if DANMAKU.sources[query] ~= nil then
DANMAKU.sources[query]["from"] = "user_local"
DANMAKU.sources[query]["data"] = parse_danmaku_file(path)
else
DANMAKU.sources[query] = {from = "user_local", data = parse_danmaku_file(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)
handle_danmaku_data(query, data, from_menu)
end)
end
-- 将弹幕转换为 Lua table
function save_danmaku_to_list(comments)
local danmaku_list = {}
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 time = tonumber(fields[1])
local type = tonumber(fields[2])
local color = tonumber(fields[3]) or 0xFFFFFF
local size = 25
local m_value = comment["m"]
:gsub("[%z\1-\31]", "")
:gsub("\\", "")
:gsub("\"", "")
table.insert(danmaku_list, {
time = time,
type = type,
size = size,
color = color,
text = m_value
})
end
end
return danmaku_list
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