update
This commit is contained in:
@@ -60,21 +60,28 @@ icc/ # ICC 色彩配置文件
|
|||||||
|
|
||||||
## 更新流程
|
## 更新流程
|
||||||
|
|
||||||
1. 在 mpv 中按 `M` 触发 manager.lua,观察控制台输出,确认无 `FAILED` 条目。可能会有其他报错如 `[manager] Fehler: externes Repository manager existiert bereits.`,这是正常的。只需要确保不出现全大写的 `FAILED` 即可。
|
1. 在 mpv 中按 `M` 触发 manager.lua,观察控制台输出,确认无 `FAILED` 条目。可能会有其他报错如 `[manager] error: remote manager already exists.`,这是正常的。只需要确保不出现全大写的 `FAILED` 即可。
|
||||||
2. 将 manager.lua 在 dest 目录产生的嵌套 `.git` 目录迁移到仓库内的 `.manager/`(避免根仓库误判为 submodule,幂等可重复执行):
|
2. 将 manager.lua 在 dest 目录产生的嵌套 `.git` 目录迁移到仓库内的 `.manager/`(避免根仓库误判为 submodule,幂等可重复执行):
|
||||||
```bash
|
```bash
|
||||||
REPO=$(git -C ~/.config/mpv rev-parse --show-toplevel)
|
REPO=$(git -C ~/.config/mpv rev-parse --show-toplevel)
|
||||||
GITSTORE="$REPO/.manager"
|
GITSTORE="$REPO/.manager"
|
||||||
mkdir -p "$GITSTORE"
|
mkdir -p "$GITSTORE"
|
||||||
find -L ~/.config/mpv -mindepth 2 -name .git -type d | while read gitdir; do
|
find "$REPO" -mindepth 2 -name .git -type d -not -path "$GITSTORE/*" | while read gitdir; do
|
||||||
dest="${gitdir%/.git}"
|
dest="${gitdir%/.git}"
|
||||||
rel="${dest#$REPO/}"
|
rel="${dest#$REPO/}"
|
||||||
name=$(echo "$rel" | tr '/' '-')
|
name=$(echo "$rel" | tr '/' '-')
|
||||||
|
[ -n "$name" ] || continue
|
||||||
|
[ -d "$GITSTORE/$name" ] && rm -rf "${GITSTORE:?}/$name"
|
||||||
mv "$gitdir" "$GITSTORE/$name"
|
mv "$gitdir" "$GITSTORE/$name"
|
||||||
depth=$(echo "$rel" | tr -cd '/' | wc -c)
|
depth=$(echo "$rel" | tr -cd '/' | wc -c)
|
||||||
ups=$(printf '../%.0s' $(seq 1 $((depth + 1))))
|
ups=$(printf '../%.0s' $(seq 1 $((depth + 1))))
|
||||||
echo "gitdir: ${ups}.manager/$name" > "$dest/.git"
|
echo "gitdir: ${ups}.manager/$name" > "$dest/.git"
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
校验(应无输出):
|
||||||
|
```bash
|
||||||
|
find "$REPO" -mindepth 2 -name .git -type f -printf '%h\0' \
|
||||||
|
| xargs -0 -I{} sh -c 'git -C "{}" rev-parse --git-dir >/dev/null 2>&1 || echo "BROKEN: {}"'
|
||||||
|
```
|
||||||
3. 重启 mpv,检查控制台有无 `unknown key` 或脚本加载失败的警告
|
3. 重启 mpv,检查控制台有无 `unknown key` 或脚本加载失败的警告
|
||||||
4. 若有 `unknown key` 警告,说明对应脚本的配置项发生变化,找 `script-opts/` 下同名 `.conf` 对照脚本源码更新
|
4. 若有 `unknown key` 警告,说明对应脚本的配置项发生变化,找 `script-opts/` 下同名 `.conf` 对照脚本源码更新
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ===== 基础设置 =====
|
# ===== 基础设置 =====
|
||||||
|
|
||||||
vo=gpu-next # 视频输出驱动
|
vo=gpu-next # 视频输出驱动
|
||||||
gpu-api=vulkan # 图形绘制接口
|
gpu-api=vulkan # 图形绘制接口
|
||||||
input-ime=no # 仅文本输入时激活 IME
|
input-ime=no # 仅文本输入时激活 IME
|
||||||
autofit-smaller=40%x30% # 窗口最小占屏比例
|
autofit-smaller=40%x30% # 窗口最小占屏比例
|
||||||
@@ -16,7 +16,7 @@ watch-later-options=start,vid,aid,sid
|
|||||||
save-watch-history=yes # 保存播放历史(内置 select 脚本可浏览)
|
save-watch-history=yes # 保存播放历史(内置 select 脚本可浏览)
|
||||||
watch-history-path=~~/files/watch_history.jsonl
|
watch-history-path=~~/files/watch_history.jsonl
|
||||||
reset-on-next-file=vid,aid,sid,secondary-sid,vf,af,loop-file,deinterlace,contrast,brightness,gamma,saturation,hue,video-zoom,video-rotate,video-pan-x,video-pan-y,panscan,speed,audio-delay,sub-pos,sub-scale,sub-delay,sub-speed,sub-visibility,secondary-sub-visibility
|
reset-on-next-file=vid,aid,sid,secondary-sid,vf,af,loop-file,deinterlace,contrast,brightness,gamma,saturation,hue,video-zoom,video-rotate,video-pan-x,video-pan-y,panscan,speed,audio-delay,sub-pos,sub-scale,sub-delay,sub-speed,sub-visibility,secondary-sub-visibility
|
||||||
input-ipc-server=/tmp/mpvsocket # IPC 套接字
|
input-ipc-server=/tmp/mpvsocket # IPC 套接字
|
||||||
directory-mode=ignore # 打开目录时忽略子目录
|
directory-mode=ignore # 打开目录时忽略子目录
|
||||||
metadata-codepage=auto # 元数据编码自动检测
|
metadata-codepage=auto # 元数据编码自动检测
|
||||||
msg-level=all=info,auto_profiles=warn
|
msg-level=all=info,auto_profiles=warn
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ force=no
|
|||||||
#如果 mpv 无法做到这一点,请尝试重新选择最后一个轨道,例如当信息更改时,默认 no
|
#如果 mpv 无法做到这一点,请尝试重新选择最后一个轨道,例如当信息更改时,默认 no
|
||||||
smart_keep=no
|
smart_keep=no
|
||||||
## 指定需忽略的特殊协议
|
## 指定需忽略的特殊协议
|
||||||
special_protocols=["://", "^magnet:"]
|
#special_protocols=["://", "^magnet:"]
|
||||||
|
|
||||||
#视频轨道筛选项设置
|
#视频轨道筛选项设置
|
||||||
#preferred_video_lang=
|
#preferred_video_lang=
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
## 指定弹幕服务器地址,自定义服务需兼容 dandanplay 的 api
|
## 指定弹幕服务器地址,自定义服务需兼容 dandanplay 的 api
|
||||||
api_server=https://api.dandanplay.net
|
api_server=https://danmaku-api.152468.xyz
|
||||||
## 指定 b 站和爱腾优的弹幕获取的兜底服务器地址,主要用于获取非动画弹幕
|
## 指定 b 站和爱腾优的弹幕获取的兜底服务器地址,主要用于获取非动画弹幕
|
||||||
## 服务器可以自托管:https://github.com/lyz05/danmaku
|
## 服务器可以自托管:https://github.com/lyz05/danmaku
|
||||||
#fallback_server=https://fc.lyz05.cn
|
#fallback_server=https://fc.lyz05.cn
|
||||||
@@ -8,7 +8,7 @@ api_server=https://api.dandanplay.net
|
|||||||
## 注意:自定义此参数时还需要对获取到的 API Key 进行 base64 编码
|
## 注意:自定义此参数时还需要对获取到的 API Key 进行 base64 编码
|
||||||
#tmdb_api_key=NmJmYjIxOTZkNzIyN2UyMTIzMGM3Y2YzZjQ4MDNkZGM=
|
#tmdb_api_key=NmJmYjIxOTZkNzIyN2UyMTIzMGM3Y2YzZjQ4MDNkZGM=
|
||||||
## 加载更多来自弹幕服务器上第三方的弹幕
|
## 加载更多来自弹幕服务器上第三方的弹幕
|
||||||
load_more_danmaku=yes
|
#load_more_danmaku=yes
|
||||||
## 自动加载网络弹幕
|
## 自动加载网络弹幕
|
||||||
auto_load=no
|
auto_load=no
|
||||||
## 自动加载本地弹幕
|
## 自动加载本地弹幕
|
||||||
@@ -38,7 +38,7 @@ fixtime=5
|
|||||||
## 字体
|
## 字体
|
||||||
fontname=danmaku
|
fontname=danmaku
|
||||||
## 字体大小
|
## 字体大小
|
||||||
fontsize=24
|
fontsize=28
|
||||||
## 透明度
|
## 透明度
|
||||||
opacity=0.6
|
opacity=0.6
|
||||||
## 粗体
|
## 粗体
|
||||||
@@ -59,4 +59,3 @@ title_replace=[{"rules":[{ "^〔(.-)〕": "%1"},{ "^.*《(.-)》": "%1" }]}]
|
|||||||
## 指定哈希匹配中需忽略的共享盘(挂载盘)的路径/目录。支持绝对路径和相对路径,多个路径用逗号分隔
|
## 指定哈希匹配中需忽略的共享盘(挂载盘)的路径/目录。支持绝对路径和相对路径,多个路径用逗号分隔
|
||||||
## 示例:["X:", "Z:", "F:/Download/", "Download"]
|
## 示例:["X:", "Z:", "F:/Download/", "Download"]
|
||||||
#excluded_path=
|
#excluded_path=
|
||||||
|
|
||||||
|
|||||||
@@ -100,11 +100,11 @@ local function is_protocol(path)
|
|||||||
return type(path) == 'string' and (path:find('^%a[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= nil)
|
return type(path) == 'string' and (path:find('^%a[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
function url_decode(str)
|
local function hex_to_char(x)
|
||||||
local function hex_to_char(x)
|
return string.char(tonumber(x, 16))
|
||||||
return string.char(tonumber(x, 16))
|
end
|
||||||
end
|
|
||||||
|
|
||||||
|
local function url_decode(str)
|
||||||
if str ~= nil then
|
if str ~= nil then
|
||||||
str = str:gsub('^%a[%a%d-_]+://', '')
|
str = str:gsub('^%a[%a%d-_]+://', '')
|
||||||
:gsub('^%a[%a%d-_]+:\\?', '')
|
:gsub('^%a[%a%d-_]+:\\?', '')
|
||||||
@@ -112,9 +112,13 @@ function url_decode(str)
|
|||||||
if str:find('://localhost:?') then
|
if str:find('://localhost:?') then
|
||||||
str = str:gsub('^.*/', '')
|
str = str:gsub('^.*/', '')
|
||||||
end
|
end
|
||||||
str = str:gsub('[\\/:%?]*', '')
|
str = str:gsub("%?.+", ""):gsub("%+", " ")
|
||||||
return str
|
local last_pos = str:match('.*[\\/:%?]()')
|
||||||
|
if last_pos then
|
||||||
|
str = str:sub(last_pos)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
return str
|
||||||
end
|
end
|
||||||
|
|
||||||
--create global_chapters_dir if it doesn't exist
|
--create global_chapters_dir if it doesn't exist
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
|
|
||||||
local msg = require "mp.msg"
|
local msg = require "mp.msg"
|
||||||
|
|
||||||
|
local function is_protocol(path)
|
||||||
|
return type(path) == 'string' and (path:find('^%a[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
local function fix_avsync()
|
local function fix_avsync()
|
||||||
local paused = mp.get_property_bool("pause")
|
local paused = mp.get_property_bool("pause")
|
||||||
msg.info("fix A/V sync.")
|
msg.info("fix A/V sync.")
|
||||||
@@ -19,7 +24,9 @@ end
|
|||||||
mp.observe_property("current-ao", "native", function(_, device)
|
mp.observe_property("current-ao", "native", function(_, device)
|
||||||
local aid = mp.get_property_number("aid")
|
local aid = mp.get_property_number("aid")
|
||||||
local has_af = mp.get_property("af", "") ~= ""
|
local has_af = mp.get_property("af", "") ~= ""
|
||||||
if device and aid and has_af then
|
local path = mp.get_property("path")
|
||||||
|
local time = mp.get_property_native("time-pos")
|
||||||
|
if device and aid and has_af and time > 0 and not is_protocol(path) then
|
||||||
fix_avsync()
|
fix_avsync()
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
+60
-34
@@ -20,14 +20,18 @@ local o = {
|
|||||||
-- Specify whether to switch HDR mode only when the window is in fullscreen or window maximized
|
-- Specify whether to switch HDR mode only when the window is in fullscreen or window maximized
|
||||||
-- only works with hdr_mode = "switch", default: false
|
-- only works with hdr_mode = "switch", default: false
|
||||||
fullscreen_only = false,
|
fullscreen_only = false,
|
||||||
-- Specify the target peak of the HDR display, default: 203
|
-- Specify the target peak of the HDR display, default: 0
|
||||||
-- must be the true peak brightness of the monitor,
|
-- By default, the display peak information property provided by mpv-display-plugin
|
||||||
-- otherwise it will cause HDR content to display incorrectly
|
-- will be used: "user-data/display-info/max-luminance"
|
||||||
target_peak = "203",
|
-- Specific values can also be specified to override internal detection,
|
||||||
|
-- but it must be the true peak brightness of the monitor,
|
||||||
|
target_peak = "0",
|
||||||
-- Specifies the measured contrast of the output display.
|
-- Specifies the measured contrast of the output display.
|
||||||
-- Used in black point compensation during HDR tone-mapping and HDR passthrough.
|
-- Used in black point compensation during HDR tone-mapping and HDR passthrough.
|
||||||
-- Must be the true contrast information of the display, e.g. 100000 means 100000:1 maximum contrast
|
-- Must be the true contrast information of the display, e.g. 100000 means 100000:1 maximum contrast
|
||||||
-- OLED display do not need to change this, default: auto
|
-- The default value auto will try to use the display black level attribute provided
|
||||||
|
-- by mpv-display-plugin to calculate the correct value: "user-data/display-info/min-luminance"
|
||||||
|
-- OLED displays can specify the special value "inf" to apply infinite contrast
|
||||||
target_contrast = "auto",
|
target_contrast = "auto",
|
||||||
}
|
}
|
||||||
options.read_options(o, _, function() end)
|
options.read_options(o, _, function() end)
|
||||||
@@ -48,6 +52,10 @@ local state = {
|
|||||||
inverse_mapping = mp.get_property_native("inverse-tone-mapping")
|
inverse_mapping = mp.get_property_native("inverse-tone-mapping")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
local function is_protocol(path)
|
||||||
|
return type(path) == 'string' and (path:find('^%a[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= nil)
|
||||||
|
end
|
||||||
|
|
||||||
local function query_hdr_state()
|
local function query_hdr_state()
|
||||||
hdr_supported = mp.get_property_native("user-data/display-info/hdr-supported")
|
hdr_supported = mp.get_property_native("user-data/display-info/hdr-supported")
|
||||||
hdr_active = mp.get_property_native("user-data/display-info/hdr-status") == "on"
|
hdr_active = mp.get_property_native("user-data/display-info/hdr-status") == "on"
|
||||||
@@ -64,7 +72,7 @@ local function apply_hdr_settings()
|
|||||||
mp.set_property_native("icc-profile-auto", false)
|
mp.set_property_native("icc-profile-auto", false)
|
||||||
mp.set_property_native("target-prim", "bt.2020")
|
mp.set_property_native("target-prim", "bt.2020")
|
||||||
mp.set_property_native("target-trc", "pq")
|
mp.set_property_native("target-trc", "pq")
|
||||||
mp.set_property_native("target-peak", o.target_peak)
|
mp.set_property_native("target-peak", tonumber(o.target_peak))
|
||||||
mp.set_property_native("target-contrast", o.target_contrast)
|
mp.set_property_native("target-contrast", o.target_contrast)
|
||||||
mp.set_property_native("target-colorspace-hint", "yes")
|
mp.set_property_native("target-colorspace-hint", "yes")
|
||||||
mp.set_property_native("inverse-tone-mapping", "no")
|
mp.set_property_native("inverse-tone-mapping", "no")
|
||||||
@@ -116,27 +124,26 @@ end
|
|||||||
|
|
||||||
local function handle_hdr_logic(paused_before, target_peak, target_prim, target_trc)
|
local function handle_hdr_logic(paused_before, target_peak, target_prim, target_trc)
|
||||||
query_hdr_state()
|
query_hdr_state()
|
||||||
if hdr_active and o.hdr_mode ~= "noth" then
|
if hdr_active then
|
||||||
apply_hdr_settings()
|
apply_hdr_settings()
|
||||||
resume_if_needed(paused_before)
|
resume_if_needed(paused_before)
|
||||||
elseif not hdr_active and o.hdr_mode ~= "noth" and
|
elseif (tonumber(target_peak) ~= 203 or target_prim == "bt.2020" or
|
||||||
(tonumber(target_peak) ~= 203 or target_prim == "bt.2020" or target_trc == "pq") then
|
target_trc == "pq") then
|
||||||
apply_sdr_settings()
|
apply_sdr_settings()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function handle_sdr_logic(paused_before, target_peak, target_prim, target_trc)
|
local function handle_sdr_logic(paused_before, target_peak, target_prim, target_trc)
|
||||||
query_hdr_state()
|
query_hdr_state()
|
||||||
if not hdr_active or o.hdr_mode ~= "noth" then
|
if not hdr_active then
|
||||||
if (not hdr_active or not state.inverse_mapping) and
|
if not state.inverse_mapping and (tonumber(target_peak) ~= 203 or
|
||||||
(tonumber(target_peak) ~= 203 or target_prim == "bt.2020" or target_trc == "pq") then
|
target_prim == "bt.2020" or target_trc == "pq") then
|
||||||
apply_sdr_settings()
|
apply_sdr_settings()
|
||||||
elseif hdr_active and state.inverse_mapping then
|
elseif state.inverse_mapping then
|
||||||
reset_target_settings()
|
reset_target_settings()
|
||||||
end
|
end
|
||||||
resume_if_needed(paused_before)
|
resume_if_needed(paused_before)
|
||||||
end
|
elseif o.hdr_mode == "pass" and state.inverse_mapping then
|
||||||
if hdr_active and o.hdr_mode == "pass" and state.inverse_mapping then
|
|
||||||
reset_target_settings()
|
reset_target_settings()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -153,11 +160,26 @@ end
|
|||||||
|
|
||||||
local function switch_hdr()
|
local function switch_hdr()
|
||||||
query_hdr_state()
|
query_hdr_state()
|
||||||
local params = mp.get_property_native("video-params")
|
local path = mp.get_property("path")
|
||||||
|
local params = mp.get_property_native("video-out-params")
|
||||||
local gamma = params and params["gamma"]
|
local gamma = params and params["gamma"]
|
||||||
local max_luma = params and params["max-luma"]
|
local max_luma = params and params["max-luma"]
|
||||||
|
local target_max_luma = mp.get_property_native("user-data/display-info/max-luminance", "203")
|
||||||
|
local target_min_luma = mp.get_property_native("user-data/display-info/min-luminance")
|
||||||
local is_hdr = max_luma and max_luma > 203
|
local is_hdr = max_luma and max_luma > 203
|
||||||
if not gamma then return end
|
if not path or not gamma then return end
|
||||||
|
|
||||||
|
if tonumber(o.target_peak) <= 203 and tonumber(target_max_luma) > 203 then
|
||||||
|
o.target_peak = math.floor(tonumber(target_max_luma))
|
||||||
|
end
|
||||||
|
|
||||||
|
if o.target_contrast == "auto" and tonumber(o.target_peak) > 203 and target_min_luma then
|
||||||
|
if tonumber(target_min_luma) == 0 then
|
||||||
|
o.target_contrast = "inf"
|
||||||
|
else
|
||||||
|
o.target_contrast = math.floor(tonumber(o.target_peak) / tonumber(target_min_luma))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local current_state = is_hdr and "hdr" or "sdr"
|
local current_state = is_hdr and "hdr" or "sdr"
|
||||||
local pause_changed = false
|
local pause_changed = false
|
||||||
@@ -168,8 +190,16 @@ local function switch_hdr()
|
|||||||
local target_trc = mp.get_property_native("target-trc")
|
local target_trc = mp.get_property_native("target-trc")
|
||||||
local is_fullscreen = fullscreen or maximized
|
local is_fullscreen = fullscreen or maximized
|
||||||
|
|
||||||
if current_state == "hdr" then
|
if current_state == "hdr" and tonumber(o.target_peak) > 203 then
|
||||||
local function continue_hdr()
|
local function continue_hdr()
|
||||||
|
local time = mp.get_property_number("time-pos")
|
||||||
|
local paused = mp.get_property_native("pause")
|
||||||
|
if time > 0 and not is_protocol(path) then
|
||||||
|
mp.commandv("frame-back-step")
|
||||||
|
if not paused then
|
||||||
|
pause_changed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
handle_hdr_logic(pause_changed, target_peak, target_prim, target_trc)
|
handle_hdr_logic(pause_changed, target_peak, target_prim, target_trc)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -189,9 +219,16 @@ local function switch_hdr()
|
|||||||
end
|
end
|
||||||
|
|
||||||
handle_hdr_logic(false, target_peak, target_prim, target_trc)
|
handle_hdr_logic(false, target_peak, target_prim, target_trc)
|
||||||
|
|
||||||
elseif current_state == "sdr" then
|
elseif current_state == "sdr" then
|
||||||
local function continue_sdr()
|
local function continue_sdr()
|
||||||
|
local time = mp.get_property_native("time-pos")
|
||||||
|
local paused = mp.get_property_native("pause")
|
||||||
|
if time > 0 and not is_protocol(path) then
|
||||||
|
mp.commandv("frame-back-step")
|
||||||
|
if not paused then
|
||||||
|
pause_changed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
handle_sdr_logic(pause_changed, target_peak, target_prim, target_trc)
|
handle_sdr_logic(pause_changed, target_peak, target_prim, target_trc)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -215,13 +252,13 @@ local function check_paramet()
|
|||||||
local target_contrast = mp.get_property_native("target-contrast")
|
local target_contrast = mp.get_property_native("target-contrast")
|
||||||
local colorspace_hint = mp.get_property_native("target-colorspace-hint")
|
local colorspace_hint = mp.get_property_native("target-colorspace-hint")
|
||||||
local inverse_mapping = mp.get_property_native("inverse-tone-mapping")
|
local inverse_mapping = mp.get_property_native("inverse-tone-mapping")
|
||||||
local params = mp.get_property_native("video-params")
|
local params = mp.get_property_native("video-out-params")
|
||||||
local gamma = params and params["gamma"]
|
local gamma = params and params["gamma"]
|
||||||
local max_luma = params and params["max-luma"]
|
local max_luma = params and params["max-luma"]
|
||||||
local is_hdr = max_luma and max_luma > 203
|
local is_hdr = max_luma and max_luma > 203
|
||||||
if not gamma then return end
|
if not gamma then return end
|
||||||
|
|
||||||
if is_hdr and hdr_active and o.hdr_mode ~= "noth" then
|
if is_hdr and hdr_active and o.hdr_mode ~= "noth" and tonumber(o.target_peak) > 203 then
|
||||||
if target_peak ~= o.target_peak then
|
if target_peak ~= o.target_peak then
|
||||||
mp.set_property_native("target-peak", o.target_peak)
|
mp.set_property_native("target-peak", o.target_peak)
|
||||||
end
|
end
|
||||||
@@ -241,14 +278,10 @@ local function check_paramet()
|
|||||||
mp.set_property_native("inverse-tone-mapping", "no")
|
mp.set_property_native("inverse-tone-mapping", "no")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if not is_hdr and o.hdr_mode ~= "noth" and not state.inverse_mapping
|
|
||||||
and (tonumber(target_peak) ~= 203 or target_prim == "bt.2020" or target_trc == "pq") then
|
|
||||||
apply_sdr_settings()
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function on_start()
|
local function on_start()
|
||||||
if o.hdr_mode == "noth" or tonumber(o.target_peak) <= 203 then
|
if o.hdr_mode == "noth" then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local vo = mp.get_property("vo")
|
local vo = mp.get_property("vo")
|
||||||
@@ -258,7 +291,7 @@ local function on_start()
|
|||||||
end
|
end
|
||||||
file_loaded = true
|
file_loaded = true
|
||||||
query_hdr_state()
|
query_hdr_state()
|
||||||
mp.observe_property("video-params", "native", switch_hdr)
|
mp.observe_property("video-out-params", "native", switch_hdr)
|
||||||
mp.observe_property("target-peak", "native", check_paramet)
|
mp.observe_property("target-peak", "native", check_paramet)
|
||||||
mp.observe_property("target-prim", "native", check_paramet)
|
mp.observe_property("target-prim", "native", check_paramet)
|
||||||
mp.observe_property("target-trc", "native", check_paramet)
|
mp.observe_property("target-trc", "native", check_paramet)
|
||||||
@@ -285,13 +318,6 @@ local function on_end(event)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function on_idle(_, active)
|
local function on_idle(_, active)
|
||||||
local target_peak = mp.get_property_native("target-peak")
|
|
||||||
local target_prim = mp.get_property_native("target-prim")
|
|
||||||
local target_trc = mp.get_property_native("target-trc")
|
|
||||||
if active and o.hdr_mode ~= "noth" and
|
|
||||||
(tonumber(target_peak) ~= 203 or target_prim == "bt.2020" or target_trc == "pq") then
|
|
||||||
apply_sdr_settings()
|
|
||||||
end
|
|
||||||
if active and file_loaded and o.hdr_mode == "switch" then
|
if active and file_loaded and o.hdr_mode == "switch" then
|
||||||
file_loaded = false
|
file_loaded = false
|
||||||
query_hdr_state()
|
query_hdr_state()
|
||||||
|
|||||||
@@ -0,0 +1,670 @@
|
|||||||
|
--[[
|
||||||
|
SOURCE_: https://github.com/WatanabeChika/mpv-lines-meme-generator
|
||||||
|
Modify_: https://github.com/dyphire/mpv-scripts
|
||||||
|
|
||||||
|
create long graph of lines with mpv
|
||||||
|
requires ffmpeg.
|
||||||
|
|
||||||
|
Usage: add bindings to input.conf
|
||||||
|
-- key script-message crop-screenshot
|
||||||
|
-- key script-message take-screenshot
|
||||||
|
-- key script-message stitch-images
|
||||||
|
]]
|
||||||
|
|
||||||
|
require("mp.options")
|
||||||
|
local utils = require("mp.utils")
|
||||||
|
local assdraw = require 'mp.assdraw'
|
||||||
|
local msg = require 'mp.msg'
|
||||||
|
|
||||||
|
local options = {
|
||||||
|
ffmpeg_path = "ffmpeg",
|
||||||
|
screenshot_dir = "~~/screenshots", -- your path to save screenshots
|
||||||
|
lossless = false, -- use lossless screenshots
|
||||||
|
ffmpeg_loglevel = "error", -- ffmpeg log level
|
||||||
|
}
|
||||||
|
|
||||||
|
read_options(options, _, function() end)
|
||||||
|
|
||||||
|
local screenshot_dir = mp.command_native({ "expand-path", options.screenshot_dir })
|
||||||
|
local screenshot_format = options.lossless and ".png" or ".jpg"
|
||||||
|
local screenshot_count = 0
|
||||||
|
local subtitle_top, subtitle_bottom = 0, 0
|
||||||
|
local screenshots = {}
|
||||||
|
|
||||||
|
-- detect path separator, detect path separator, windows uses backslashes
|
||||||
|
local is_windows = package.config:sub(1, 1) == "\\"
|
||||||
|
--create screenshot_dir if it doesn't exist
|
||||||
|
if screenshot_dir ~= '' then
|
||||||
|
local meta = utils.file_info(screenshot_dir)
|
||||||
|
if not meta or not meta.is_dir then
|
||||||
|
local windows_args = { 'powershell', '-NoProfile', '-Command', 'mkdir',
|
||||||
|
string.format("\"%s\"", screenshot_dir) }
|
||||||
|
local unix_args = { 'mkdir', '-p', screenshot_dir }
|
||||||
|
local args = is_windows and windows_args or unix_args
|
||||||
|
local res = mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
capture_stdout = true,
|
||||||
|
playback_only = false,
|
||||||
|
args = args
|
||||||
|
})
|
||||||
|
if res.status ~= 0 then
|
||||||
|
msg.error("Failed to create screenshot_dir save directory " .. screenshot_dir ..
|
||||||
|
". Error: " .. (res.error or "unknown"))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function file_exist(path)
|
||||||
|
local meta = utils.file_info(path)
|
||||||
|
if not meta or not meta.is_file then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- helper: crop an input image file into output using mpv
|
||||||
|
local function mpv_crop_file(input_path, out_path, crop_arg)
|
||||||
|
local ENCODER_MAP = {
|
||||||
|
[".png"] = "png",
|
||||||
|
[".jpg"] = "mjpeg",
|
||||||
|
}
|
||||||
|
local ovc = ENCODER_MAP[screenshot_format] or "png"
|
||||||
|
|
||||||
|
local cmd = { "mpv", input_path, "--no-config" }
|
||||||
|
if crop_arg and crop_arg ~= "" then
|
||||||
|
table.insert(cmd, "--vf=" .. crop_arg)
|
||||||
|
end
|
||||||
|
table.insert(cmd, "--frames=1")
|
||||||
|
table.insert(cmd, "--ovc=" .. ovc)
|
||||||
|
table.insert(cmd, "-o")
|
||||||
|
table.insert(cmd, out_path)
|
||||||
|
|
||||||
|
local res = utils.subprocess({ args = cmd, capture_stdout = true, capture_stderr = true })
|
||||||
|
if res and res.status ~= 0 then
|
||||||
|
msg.error("mpv crop failed: status=" .. tostring(res.status) .. " error=" .. tostring(res.error))
|
||||||
|
if res.stdout then msg.error("mpv stdout: " .. tostring(res.stdout)) end
|
||||||
|
if res.stderr then msg.error("mpv stderr: " .. tostring(res.stderr)) end
|
||||||
|
end
|
||||||
|
return res
|
||||||
|
end
|
||||||
|
|
||||||
|
-- take and crop screenshots
|
||||||
|
local function perform_capture(idx, crop_arg_local, line)
|
||||||
|
-- take original shots
|
||||||
|
local screenshot_file = utils.join_path(screenshot_dir, "temp_screenshot_" .. idx .. screenshot_format)
|
||||||
|
mp.commandv("screenshot-to-file", screenshot_file, "subtitles")
|
||||||
|
|
||||||
|
-- crop the shots
|
||||||
|
local processed_file = line and
|
||||||
|
utils.join_path(screenshot_dir, string.format("line_shot_%03d" .. screenshot_format, idx)) or
|
||||||
|
utils.join_path(screenshot_dir, string.format("crop_shot_%s%s", os.date("%Y%m%d_%H%M%S"), screenshot_format))
|
||||||
|
|
||||||
|
-- use helper to crop/export the temporary screenshot into final output
|
||||||
|
local result = mpv_crop_file(screenshot_file, processed_file, crop_arg_local)
|
||||||
|
if result and result.status == 0 then
|
||||||
|
mp.osd_message("Shot saved: " .. processed_file)
|
||||||
|
msg.verbose("Shot saved: " .. processed_file)
|
||||||
|
if line then
|
||||||
|
table.insert(screenshots, processed_file)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
mp.osd_message("Cropping failed")
|
||||||
|
msg.verbose("Cropping failed: " .. (result and result.error or "unknown"))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- delete the original shots
|
||||||
|
os.remove(screenshot_file)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Minimal DisplayState derived from mpv_crop_script's implementation
|
||||||
|
local DisplayState = {}
|
||||||
|
DisplayState.__index = DisplayState
|
||||||
|
|
||||||
|
function DisplayState.new()
|
||||||
|
local self = setmetatable({}, DisplayState)
|
||||||
|
self.screen = { width = 0, height = 0 }
|
||||||
|
self.video = { width = 0, height = 0 }
|
||||||
|
self.bounds = { left = 0, top = 0, right = 0, bottom = 0, width = 0, height = 0 }
|
||||||
|
self.scale = { x = 1, y = 1 }
|
||||||
|
self.current_state = nil
|
||||||
|
self.screen_ready = false
|
||||||
|
self.video_ready = false
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
function DisplayState:_collect_display_state()
|
||||||
|
local screen_w, screen_h = mp.get_osd_size()
|
||||||
|
local state = {
|
||||||
|
screen_w = screen_w,
|
||||||
|
screen_h = screen_h,
|
||||||
|
video_w = mp.get_property_number("dwidth"),
|
||||||
|
video_h = mp.get_property_number("dheight"),
|
||||||
|
video_w_raw = mp.get_property_number("video-out-params/w"),
|
||||||
|
video_h_raw = mp.get_property_number("video-out-params/h"),
|
||||||
|
video_aspect_override = mp.get_property_native("video-aspect-override"),
|
||||||
|
panscan = mp.get_property_native("panscan"),
|
||||||
|
video_zoom = mp.get_property_native("video-zoom"),
|
||||||
|
video_unscaled = mp.get_property_native("video-unscaled"),
|
||||||
|
video_align_x = mp.get_property_native("video-align-x"),
|
||||||
|
video_align_y = mp.get_property_native("video-align-y"),
|
||||||
|
video_pan_x = mp.get_property_native("video-pan-x"),
|
||||||
|
video_pan_y = mp.get_property_native("video-pan-y"),
|
||||||
|
fullscreen = mp.get_property_native("fullscreen"),
|
||||||
|
keepaspect = mp.get_property_native("keepaspect"),
|
||||||
|
keepaspect_window = mp.get_property_native("keepaspect-window"),
|
||||||
|
window_maximized = mp.get_property_native("window-maximized")
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
end
|
||||||
|
|
||||||
|
function DisplayState:_aspect_calc_panscan(state)
|
||||||
|
local f_width = state.screen_w
|
||||||
|
local f_height = (state.screen_w / state.video_w) * state.video_h
|
||||||
|
if f_height > state.screen_h or f_height < state.video_h_raw then
|
||||||
|
local tmp_w = (state.screen_h / state.video_h) * state.video_w
|
||||||
|
if tmp_w <= state.screen_w then
|
||||||
|
f_height = state.screen_h
|
||||||
|
f_width = tmp_w
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local vo_panscan_area = state.screen_h - f_height
|
||||||
|
local f_w = f_width / f_height
|
||||||
|
local f_h = 1
|
||||||
|
if (vo_panscan_area == 0) then
|
||||||
|
vo_panscan_area = state.screen_w - f_width
|
||||||
|
f_w = 1
|
||||||
|
f_h = f_height / f_width
|
||||||
|
end
|
||||||
|
if state.video_unscaled then
|
||||||
|
vo_panscan_area = 0
|
||||||
|
if state.video_unscaled ~= "downscale-big" or ((state.video_w <= state.screen_w)
|
||||||
|
and (state.video_h <= state.screen_h)) then
|
||||||
|
f_width = state.video_w
|
||||||
|
f_height = state.video_h
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local scaled_w = math.floor( f_width + vo_panscan_area * state.panscan * f_w )
|
||||||
|
local scaled_h = math.floor( f_height + vo_panscan_area * state.panscan * f_h )
|
||||||
|
return scaled_w, scaled_h
|
||||||
|
end
|
||||||
|
|
||||||
|
function DisplayState:_split_scaling(dst_size, scaled_src_size, zoom, align, pan)
|
||||||
|
scaled_src_size = math.floor(scaled_src_size * 2^zoom)
|
||||||
|
align = (align + 1) / 2
|
||||||
|
local dst_start = (dst_size - scaled_src_size) * align + pan * scaled_src_size
|
||||||
|
local dst_end = dst_start + scaled_src_size
|
||||||
|
return math.floor(dst_start), math.floor(dst_end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function DisplayState:recalculate_bounds(forced)
|
||||||
|
local new_state = self:_collect_display_state()
|
||||||
|
if not (forced or self.current_state == nil) then
|
||||||
|
local changed = false
|
||||||
|
for k in pairs(new_state) do
|
||||||
|
if new_state[k] ~= self.current_state[k] then
|
||||||
|
changed = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not changed then return self.screen_ready end
|
||||||
|
end
|
||||||
|
self.current_state = new_state
|
||||||
|
self.screen.width = new_state.screen_w
|
||||||
|
self.screen.height = new_state.screen_h
|
||||||
|
if new_state.video_w and new_state.video_h then
|
||||||
|
self.video.width = new_state.video_w
|
||||||
|
self.video.height = new_state.video_h
|
||||||
|
|
||||||
|
-- Apply video-aspect-override when present (e.g. "16:9" or "1.7777") by adjusting raw output dims
|
||||||
|
local vao = new_state.video_aspect_override
|
||||||
|
if vao and vao ~= false and tostring(vao) ~= "no" then
|
||||||
|
local s = tostring(vao)
|
||||||
|
-- Try parse A:B style
|
||||||
|
local na, nb = s:match("^(%d+)%s*[:xX]%s*(%d+)$")
|
||||||
|
local ar = nil
|
||||||
|
if na and nb then
|
||||||
|
ar = tonumber(na) / tonumber(nb)
|
||||||
|
else
|
||||||
|
local f = tonumber(s)
|
||||||
|
if f and f > 0 then ar = f end
|
||||||
|
end
|
||||||
|
if ar then
|
||||||
|
-- Prefer to keep video_h_raw and compute video_w_raw to match aspect
|
||||||
|
if new_state.video_h_raw and new_state.video_h_raw > 0 then
|
||||||
|
new_state.video_w_raw = math.floor(new_state.video_h_raw * ar + 0.5)
|
||||||
|
elseif new_state.video_w_raw and new_state.video_w_raw > 0 then
|
||||||
|
new_state.video_h_raw = math.floor(new_state.video_w_raw / ar + 0.5)
|
||||||
|
else
|
||||||
|
-- fallback to reported dwidth/dheight
|
||||||
|
if new_state.video_h and new_state.video_h > 0 then
|
||||||
|
new_state.video_w_raw = math.floor(new_state.video_h * ar + 0.5)
|
||||||
|
elseif new_state.video_w and new_state.video_w > 0 then
|
||||||
|
new_state.video_h_raw = math.floor(new_state.video_w / ar + 0.5)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- Also update video_w/video_h (dwidth/dheight) to reflect the override so
|
||||||
|
-- bounds calculation (which uses video_w/video_h) stays consistent with
|
||||||
|
-- the adjusted raw output dimensions. Prefer keeping video_h and
|
||||||
|
-- computing video_w, mirroring the logic above.
|
||||||
|
if new_state.video_h and new_state.video_h > 0 then
|
||||||
|
new_state.video_w = math.floor(new_state.video_h * ar + 0.5)
|
||||||
|
elseif new_state.video_w and new_state.video_w > 0 then
|
||||||
|
new_state.video_h = math.floor(new_state.video_w / ar + 0.5)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if new_state.keepaspect then
|
||||||
|
local scaled_w, scaled_h = self:_aspect_calc_panscan(new_state)
|
||||||
|
local video_left, video_right = self:_split_scaling(new_state.screen_w, scaled_w,
|
||||||
|
new_state.video_zoom, new_state.video_align_x, new_state.video_pan_x)
|
||||||
|
local video_top, video_bottom = self:_split_scaling(new_state.screen_h, scaled_h,
|
||||||
|
new_state.video_zoom, new_state.video_align_y, new_state.video_pan_y)
|
||||||
|
self.bounds = { left = video_left, right = video_right, top = video_top, bottom = video_bottom,
|
||||||
|
width = video_right - video_left, height = video_bottom - video_top }
|
||||||
|
else
|
||||||
|
self.bounds = { left = 0, top = 0, right = self.screen.width, bottom = self.screen.height,
|
||||||
|
width = self.screen.width, height = self.screen.height }
|
||||||
|
end
|
||||||
|
-- Use the same source for scale as was used to compute bounds (prefer dwidth/dheight)
|
||||||
|
local basis_w = new_state.video_w or new_state.video_w_raw
|
||||||
|
local basis_h = new_state.video_h or new_state.video_h_raw
|
||||||
|
self.scale.x = basis_w / self.bounds.width
|
||||||
|
self.scale.y = basis_h / self.bounds.height
|
||||||
|
self.video_ready = true
|
||||||
|
end
|
||||||
|
self.screen_ready = true
|
||||||
|
return self.screen_ready
|
||||||
|
end
|
||||||
|
|
||||||
|
function DisplayState:screen_to_video(x, y)
|
||||||
|
local nx = (x - self.bounds.left) * self.scale.x
|
||||||
|
local ny = (y - self.bounds.top) * self.scale.y
|
||||||
|
return nx, ny
|
||||||
|
end
|
||||||
|
|
||||||
|
function DisplayState:video_to_screen(x, y)
|
||||||
|
local nx = (x / self.scale.x) + self.bounds.left
|
||||||
|
local ny = (y / self.scale.y) + self.bounds.top
|
||||||
|
return nx, ny
|
||||||
|
end
|
||||||
|
|
||||||
|
-- create one instance for use
|
||||||
|
display_state = DisplayState.new()
|
||||||
|
-- store observer ids in a local table so we can unobserve on unload
|
||||||
|
local display_observer_ids = {}
|
||||||
|
|
||||||
|
-- Register observers for properties that affect display mapping.
|
||||||
|
-- When any of these change, recalculate the display bounds once.
|
||||||
|
local function register_display_observers()
|
||||||
|
local props = {
|
||||||
|
"dwidth", "dheight",
|
||||||
|
"video-out-params/w", "video-out-params/h",
|
||||||
|
"video-aspect-override",
|
||||||
|
"panscan", "video-zoom", "video-unscaled",
|
||||||
|
"video-align-x", "video-align-y",
|
||||||
|
"video-pan-x", "video-pan-y",
|
||||||
|
"keepaspect", "keepaspect-window",
|
||||||
|
"fullscreen", "window-maximized"
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p in ipairs(props) do
|
||||||
|
local cb = function(name, value)
|
||||||
|
if display_state and display_state.recalculate_bounds then
|
||||||
|
display_state:recalculate_bounds(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local id = mp.observe_property(p, "native", cb)
|
||||||
|
table.insert(display_observer_ids, id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function unregister_display_observers()
|
||||||
|
if not display_observer_ids then return end
|
||||||
|
for _, id in ipairs(display_observer_ids) do
|
||||||
|
mp.unobserve_property(id)
|
||||||
|
end
|
||||||
|
display_observer_ids = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function osd_to_video_coords(x, y)
|
||||||
|
-- Use cached DisplayState
|
||||||
|
if display_state and display_state.recalculate_bounds then
|
||||||
|
-- Ensure display state is up-to-date and video mapping is ready
|
||||||
|
display_state:recalculate_bounds()
|
||||||
|
if display_state.video_ready then
|
||||||
|
local vx, vy = display_state:screen_to_video(x, y)
|
||||||
|
return math.floor(vx + 0.5), math.floor(vy + 0.5)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Simplified interactive ASS cropper
|
||||||
|
local asscrop_active = false
|
||||||
|
local asscrop_start_pos = nil
|
||||||
|
local asscrop_end_pos = nil
|
||||||
|
local asscrop_timer = nil
|
||||||
|
local asscrop_count = 0
|
||||||
|
local asscrop_callback = nil
|
||||||
|
|
||||||
|
local function asscrop_update()
|
||||||
|
if not asscrop_overlay then return end
|
||||||
|
|
||||||
|
local ass = assdraw.ass_new()
|
||||||
|
if asscrop_start_pos then
|
||||||
|
local sx, sy = asscrop_start_pos[1], asscrop_start_pos[2]
|
||||||
|
local mx, my = mp.get_mouse_pos()
|
||||||
|
local ex, ey
|
||||||
|
if asscrop_end_pos then
|
||||||
|
ex, ey = asscrop_end_pos[1], asscrop_end_pos[2]
|
||||||
|
else
|
||||||
|
ex, ey = mx, my
|
||||||
|
end
|
||||||
|
|
||||||
|
local x0 = math.min(sx, ex)
|
||||||
|
local y0 = math.min(sy, ey)
|
||||||
|
local x1 = math.max(sx, ex)
|
||||||
|
local y1 = math.max(sy, ey)
|
||||||
|
|
||||||
|
local osd_w, osd_h = mp.get_osd_size()
|
||||||
|
|
||||||
|
-- dim outside area using inverse clip so the interior stays untouched
|
||||||
|
local overlay_transparency = 160 -- 0-255
|
||||||
|
local overlay_lightness = 0
|
||||||
|
local format_dim = string.format("{\\bord0\\1a&H%02X&\\1c&H%02X%02X%02X&}",
|
||||||
|
overlay_transparency,
|
||||||
|
overlay_lightness,
|
||||||
|
overlay_lightness,
|
||||||
|
overlay_lightness)
|
||||||
|
|
||||||
|
ass:new_event()
|
||||||
|
ass:append(string.format("{\\iclip(%d,%d,%d,%d)}", x0, y0, x1, y1))
|
||||||
|
ass:pos(0,0)
|
||||||
|
ass:append(format_dim)
|
||||||
|
ass:draw_start()
|
||||||
|
ass:rect_cw(0,0, osd_w, osd_h)
|
||||||
|
ass:draw_stop()
|
||||||
|
|
||||||
|
-- Draw border using transparent primary (no fill) and \\3c for outline color
|
||||||
|
local line_color = 220
|
||||||
|
local box_format = string.format("{\\1a&HFF&\\1c&H000000&\\3c&H%02X%02X%02X&\\bord1}",
|
||||||
|
line_color,
|
||||||
|
line_color,
|
||||||
|
line_color)
|
||||||
|
ass:new_event()
|
||||||
|
ass:pos(0,0)
|
||||||
|
ass:append(box_format)
|
||||||
|
ass:draw_start()
|
||||||
|
ass:rect_cw(x0, y0, x1, y1)
|
||||||
|
ass:draw_stop()
|
||||||
|
|
||||||
|
-- Handles: transparent fill + white border
|
||||||
|
local hs = 10
|
||||||
|
local handle_format = "{\\1a&HFF&\\1c&H000000&\\3c&HFFFFFF&\\bord1}"
|
||||||
|
ass:new_event()
|
||||||
|
ass:pos(0,0)
|
||||||
|
ass:append(handle_format)
|
||||||
|
ass:draw_start()
|
||||||
|
ass:rect_cw(x0-hs, y0-hs, x0+hs, y0+hs)
|
||||||
|
ass:rect_cw(x1-hs, y0-hs, x1+hs, y0+hs)
|
||||||
|
ass:rect_cw(x0-hs, y1-hs, x0+hs, y1+hs)
|
||||||
|
ass:rect_cw(x1-hs, y1-hs, x1+hs, y1+hs)
|
||||||
|
ass:draw_stop()
|
||||||
|
|
||||||
|
-- Size text
|
||||||
|
local sw = math.max(0, x1 - x0)
|
||||||
|
local sh = math.max(0, y1 - y0)
|
||||||
|
ass:new_event()
|
||||||
|
ass:pos(x1 - 6, y1 + 14)
|
||||||
|
ass:an(9)
|
||||||
|
ass:append(string.format("{\\fs16\\bord2\\shad0} %dx%d", sw, sh))
|
||||||
|
else
|
||||||
|
ass:new_event()
|
||||||
|
ass:pos(10,10)
|
||||||
|
ass:append("{\\fs20\\bord2}Click once to set first corner, click again to set second. Enter to crop, Esc to cancel.")
|
||||||
|
end
|
||||||
|
|
||||||
|
local osd_w, osd_h = mp.get_osd_size()
|
||||||
|
mp.set_osd_ass(osd_w, osd_h, ass.text)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function asscrop_cancel(msg)
|
||||||
|
asscrop_start_pos = nil
|
||||||
|
asscrop_end_pos = nil
|
||||||
|
asscrop_callback = nil
|
||||||
|
if asscrop_timer then asscrop_timer:kill() asscrop_timer = nil end
|
||||||
|
mp.set_osd_ass(0,0, "")
|
||||||
|
mp.remove_key_binding("lines_asscrop_mouse")
|
||||||
|
mp.remove_key_binding("lines_asscrop_enter")
|
||||||
|
mp.remove_key_binding("lines_asscrop_esc")
|
||||||
|
asscrop_active = false
|
||||||
|
if msg then
|
||||||
|
mp.osd_message("Crop canceled")
|
||||||
|
end
|
||||||
|
mp.commandv('script-message-to', 'uosc', 'disable-elements', mp.get_script_name(), '')
|
||||||
|
end
|
||||||
|
|
||||||
|
local function asscrop_finish()
|
||||||
|
if not (asscrop_start_pos and asscrop_end_pos) then
|
||||||
|
mp.osd_message("No crop area set")
|
||||||
|
msg.info("No crop area set")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- compute video-space crop
|
||||||
|
local x1s, y1s = asscrop_start_pos[1], asscrop_start_pos[2]
|
||||||
|
local x2s, y2s = asscrop_end_pos[1], asscrop_end_pos[2]
|
||||||
|
local x0 = math.min(x1s, x2s)
|
||||||
|
local y0 = math.min(y1s, y2s)
|
||||||
|
local x1 = math.max(x1s, x2s)
|
||||||
|
local y1 = math.max(y1s, y2s)
|
||||||
|
|
||||||
|
local vx0, vy0
|
||||||
|
local vw, vh
|
||||||
|
do
|
||||||
|
local tx0, ty0 = osd_to_video_coords(x0, y0)
|
||||||
|
local tx1, ty1 = osd_to_video_coords(x1, y1)
|
||||||
|
if not tx0 or not tx1 then
|
||||||
|
mp.osd_message("Unable to determine video coordinates")
|
||||||
|
msg.warn("Unable to determine video coordinates")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
vx0, vy0 = tx0, ty0
|
||||||
|
vw = math.abs(tx1 - tx0)
|
||||||
|
vh = math.abs(ty1 - ty0)
|
||||||
|
end
|
||||||
|
|
||||||
|
if vw <= 0 or vh <= 0 then
|
||||||
|
mp.osd_message("Bad crop size")
|
||||||
|
msg.warn("Bad crop size")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If a callback is registered (used by take_screenshot first-shot flow),
|
||||||
|
-- call it with crop and skip mpv crop
|
||||||
|
if asscrop_callback then
|
||||||
|
local cb = asscrop_callback
|
||||||
|
asscrop_callback = nil
|
||||||
|
cb({ x = vx0, y = vy0, w = vw, h = vh })
|
||||||
|
asscrop_cancel(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local crop_arg = string.format("crop=%d:%d:%d:%d", vw, vh, vx0, vy0)
|
||||||
|
|
||||||
|
asscrop_count = asscrop_count + 1
|
||||||
|
perform_capture(asscrop_count, crop_arg)
|
||||||
|
asscrop_cancel(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function asscrop_mouse(e)
|
||||||
|
if not e or not e.event then return end
|
||||||
|
if e.event == "down" then
|
||||||
|
local mx, my = mp.get_mouse_pos()
|
||||||
|
if not asscrop_start_pos then
|
||||||
|
asscrop_start_pos = {mx, my}
|
||||||
|
if not asscrop_timer then asscrop_timer = mp.add_periodic_timer(1/60, asscrop_update) end
|
||||||
|
-- immediate update so rectangle follows cursor
|
||||||
|
asscrop_update()
|
||||||
|
else
|
||||||
|
asscrop_end_pos = {mx, my}
|
||||||
|
asscrop_update()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function asscrop_begin()
|
||||||
|
local path = mp.get_property("path")
|
||||||
|
if not path then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if asscrop_active then
|
||||||
|
asscrop_cancel(true)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
asscrop_active = true
|
||||||
|
asscrop_start_pos = nil
|
||||||
|
asscrop_end_pos = nil
|
||||||
|
asscrop_overlay = mp.create_osd_overlay("ass-events")
|
||||||
|
asscrop_timer = nil
|
||||||
|
|
||||||
|
-- disable certain uosc UI elements
|
||||||
|
mp.commandv('script-message-to', 'uosc', 'disable-elements', mp.get_script_name(),
|
||||||
|
'top_bar,timeline,controls,volume,idle_indicator,audio_indicator,buffering_indicator,pause_indicator')
|
||||||
|
|
||||||
|
-- forced mouse binding to capture clicks
|
||||||
|
mp.add_forced_key_binding("MBTN_LEFT", "lines_asscrop_mouse", asscrop_mouse, {complex=true})
|
||||||
|
mp.add_forced_key_binding("ENTER", "lines_asscrop_enter", function() asscrop_finish() end)
|
||||||
|
mp.add_forced_key_binding("ESC", "lines_asscrop_esc", function() asscrop_cancel(true) end)
|
||||||
|
mp.osd_message("Crop: Click first corner, then second. Enter to confirm, Esc to cancel.")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function take_screenshot()
|
||||||
|
local crop_arg = ""
|
||||||
|
local path = mp.get_property("path")
|
||||||
|
if not path then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local v_w = mp.get_property_number("dwidth") or mp.get_property_number("width", 0)
|
||||||
|
local v_h = mp.get_property_number("dheight") or mp.get_property_number("height", 0)
|
||||||
|
|
||||||
|
screenshot_count = screenshot_count + 1
|
||||||
|
if subtitle_top == 0 then
|
||||||
|
screenshot_count = 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If first shot (or subtitle not set), defer actual capture until after ASS cropper callback
|
||||||
|
if screenshot_count == 1 then
|
||||||
|
asscrop_callback = function(crop)
|
||||||
|
-- crop is video-space x,y,w,h
|
||||||
|
subtitle_top = crop.y / v_h
|
||||||
|
subtitle_bottom = (v_h - (crop.y + crop.h)) / v_h
|
||||||
|
if subtitle_top < 0 or subtitle_bottom < 0 then
|
||||||
|
subtitle_top = 0
|
||||||
|
subtitle_bottom = 0
|
||||||
|
mp.osd_message("Invalid subtitle margins\nplease make sure the subtitles are within the video frame")
|
||||||
|
msg.warn("Invalid subtitle margins, please make sure the subtitles are within the video frame")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
mp.osd_message(string.format("Subtitle region set: top=%.3f bottom=%.3f", subtitle_top, subtitle_bottom))
|
||||||
|
msg.verbose(string.format("Subtitle region set: top=%.3f bottom=%.3f", subtitle_top, subtitle_bottom))
|
||||||
|
-- now perform the actual capture using the newly set margins
|
||||||
|
local effective_subtitle_top = 1 - subtitle_bottom
|
||||||
|
local crop_h = math.max(1, math.floor(v_h * effective_subtitle_top + 0.5))
|
||||||
|
local crop_w = v_w
|
||||||
|
local crop_x = 0
|
||||||
|
local crop_y = 0
|
||||||
|
crop_arg = string.format("crop=%d:%d:%d:%d", crop_w, crop_h, crop_x, crop_y)
|
||||||
|
perform_capture(screenshot_count, crop_arg, true)
|
||||||
|
end
|
||||||
|
-- Launch the ASS cropper UI and wait for callback to do the capture
|
||||||
|
asscrop_begin()
|
||||||
|
else
|
||||||
|
local crop_height_frac = 1 - subtitle_top - subtitle_bottom
|
||||||
|
if crop_height_frac <= 0 then
|
||||||
|
screenshots = {}
|
||||||
|
screenshot_count = 0
|
||||||
|
subtitle_top = 0
|
||||||
|
subtitle_bottom = 0
|
||||||
|
mp.osd_message("Invalid subtitle margins, skipping crop")
|
||||||
|
msg.warn("Invalid subtitle margins, skipping crop")
|
||||||
|
else
|
||||||
|
local crop_h = math.max(1, math.floor(v_h * crop_height_frac + 0.5))
|
||||||
|
local crop_w = v_w
|
||||||
|
local crop_x = 0
|
||||||
|
local crop_y = math.max(0, math.floor(v_h * subtitle_top + 0.5))
|
||||||
|
crop_arg = string.format("crop=%d:%d:%d:%d", crop_w, crop_h, crop_x, crop_y)
|
||||||
|
perform_capture(screenshot_count, crop_arg, true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- stitch the cropped screenshots together
|
||||||
|
local function stitch_images()
|
||||||
|
if #screenshots <= 1 then
|
||||||
|
mp.osd_message("No shots to stitch!")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- remove entries that no longer exist on disk
|
||||||
|
for i = #screenshots, 1, -1 do
|
||||||
|
if not file_exist(screenshots[i]) then
|
||||||
|
table.remove(screenshots, i)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local command = { options.ffmpeg_path, "-loglevel", options.ffmpeg_loglevel, "-y" }
|
||||||
|
local output_file = utils.join_path(screenshot_dir, "stitched_screenshot_" ..
|
||||||
|
os.date("%Y%m%d_%H%M%S") .. screenshot_format)
|
||||||
|
local filter_expr = "vstack=" .. #screenshots
|
||||||
|
|
||||||
|
-- add input files, filter, output filename, one by one
|
||||||
|
for i = 1, #screenshots do
|
||||||
|
table.insert(command, "-i")
|
||||||
|
table.insert(command, screenshots[i])
|
||||||
|
end
|
||||||
|
table.insert(command, "-filter_complex")
|
||||||
|
table.insert(command, filter_expr)
|
||||||
|
table.insert(command, output_file)
|
||||||
|
|
||||||
|
-- run the command
|
||||||
|
local result = utils.subprocess({ args = command })
|
||||||
|
|
||||||
|
if result.status == 0 then
|
||||||
|
mp.osd_message("Stitched shot saved: " .. output_file)
|
||||||
|
msg.verbose("Stitched shot saved: " .. output_file)
|
||||||
|
else
|
||||||
|
mp.osd_message("Stitching failed")
|
||||||
|
msg.verbose("Stitching failed: " .. (result and result.error or "unknown"))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- delete the cropped shots and reset the counter
|
||||||
|
for _, img in ipairs(screenshots) do
|
||||||
|
os.remove(img)
|
||||||
|
end
|
||||||
|
|
||||||
|
screenshots = {}
|
||||||
|
screenshot_count = 0
|
||||||
|
subtitle_top = 0
|
||||||
|
subtitle_bottom = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
mp.register_event("file-loaded", function()
|
||||||
|
if display_state then
|
||||||
|
display_state:recalculate_bounds(true)
|
||||||
|
end
|
||||||
|
register_display_observers()
|
||||||
|
end)
|
||||||
|
|
||||||
|
mp.add_hook("on_unload", 50, function()
|
||||||
|
unregister_display_observers()
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- bindings
|
||||||
|
mp.add_key_binding(nil, "take-screenshot", take_screenshot)
|
||||||
|
mp.add_key_binding(nil, "stitch-images", stitch_images)
|
||||||
|
mp.add_key_binding(nil, "crop-screenshot", asscrop_begin)
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
-- Install [Torrserver](https://github.com/YouROK/TorrServer)
|
||||||
|
-- then add "script-opts-append=mpv_torrserver-server=http://[TorrServer ip]:[port]" to mpv.conf
|
||||||
|
local utils = require 'mp.utils'
|
||||||
|
|
||||||
|
local opts = {
|
||||||
|
server = "http://localhost:8090",
|
||||||
|
torrserver_init = false,
|
||||||
|
torrserver_path = "TorrServer",
|
||||||
|
search_for_external_tracks = true
|
||||||
|
}
|
||||||
|
|
||||||
|
(require 'mp.options').read_options(opts)
|
||||||
|
local luacurl_available, cURL = pcall(require, 'cURL')
|
||||||
|
|
||||||
|
local is_windows = package.config:sub(1, 1) == "\\" -- detect path separator, windows uses backslashes
|
||||||
|
|
||||||
|
local function find_executable(name)
|
||||||
|
local os_path = os.getenv("PATH") or ""
|
||||||
|
local fallback_path = utils.join_path("/usr/bin", name)
|
||||||
|
local exec_path
|
||||||
|
for path in os_path:gmatch("[^:]+") do
|
||||||
|
exec_path = utils.join_path(path, name)
|
||||||
|
local meta, meta_error = utils.file_info(exec_path)
|
||||||
|
if meta and meta.is_file then
|
||||||
|
return exec_path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not is_windows then return fallback_path end
|
||||||
|
return name -- fallback to just the name, hoping it's in PATH
|
||||||
|
end
|
||||||
|
|
||||||
|
local function init()
|
||||||
|
local exec_path = find_executable(opts.torrserver_path)
|
||||||
|
local windows_args = { 'powershell', '-NoProfile', '-Command', exec_path }
|
||||||
|
local unix_args = { '/bin/bash', '-c', exec_path }
|
||||||
|
local args = is_windows and windows_args or unix_args
|
||||||
|
local res = mp.command_native_async({ name = "subprocess", capture_stdout = true, playback_only = false, args = args })
|
||||||
|
if res.status == 0 then
|
||||||
|
mp.msg.error("TorrServer failed to start: ")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local char_to_hex = function(c)
|
||||||
|
return string.format("%%%02X", string.byte(c))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function urlencode(url)
|
||||||
|
if url == nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
url = url:gsub("\n", "\r\n")
|
||||||
|
url = url:gsub("([^%w ])", char_to_hex)
|
||||||
|
url = url:gsub(" ", "+")
|
||||||
|
return url
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_magnet_info(url)
|
||||||
|
local info_url = opts.server .. "/stream?stat&link=" .. urlencode(url)
|
||||||
|
local res
|
||||||
|
if not (luacurl_available) then
|
||||||
|
-- if Lua-cURL is not available on this system
|
||||||
|
local curl_cmd = {
|
||||||
|
"curl",
|
||||||
|
"-L",
|
||||||
|
"--silent",
|
||||||
|
"--max-time", "10",
|
||||||
|
info_url
|
||||||
|
}
|
||||||
|
local cmd = mp.command_native {
|
||||||
|
name = "subprocess",
|
||||||
|
capture_stdout = true,
|
||||||
|
playback_only = false,
|
||||||
|
args = curl_cmd
|
||||||
|
}
|
||||||
|
res = cmd.stdout
|
||||||
|
else
|
||||||
|
-- otherwise use Lua-cURL (binding to libcurl)
|
||||||
|
local buf = {}
|
||||||
|
local c = cURL.easy_init()
|
||||||
|
c:setopt_followlocation(1)
|
||||||
|
c:setopt_url(info_url)
|
||||||
|
c:setopt_writefunction(function(chunk)
|
||||||
|
table.insert(buf, chunk);
|
||||||
|
return true;
|
||||||
|
end)
|
||||||
|
c:perform()
|
||||||
|
res = table.concat(buf)
|
||||||
|
end
|
||||||
|
if res and res ~= "" then
|
||||||
|
return (require 'mp.utils').parse_json(res)
|
||||||
|
else
|
||||||
|
return nil, "no info response (timeout?)"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function edlencode(url)
|
||||||
|
return "%" .. string.len(url) .. "%" .. url
|
||||||
|
end
|
||||||
|
|
||||||
|
local function guess_type_by_extension(ext)
|
||||||
|
if ext == "mkv" or ext == "mp4" or ext == "avi" or ext == "wmv" or ext == "vob" or ext == "m2ts" or ext == "ogm" then
|
||||||
|
return "video"
|
||||||
|
end
|
||||||
|
if ext == "mka" or ext == "mp3" or ext == "aac" or ext == "flac" or ext == "ogg" or ext == "wma" or ext == "mpg"
|
||||||
|
or ext == "wav" or ext == "wv" or ext == "opus" or ext == "ac3" then
|
||||||
|
return "audio"
|
||||||
|
end
|
||||||
|
if ext == "ass" or ext == "srt" or ext == "vtt" then
|
||||||
|
return "sub"
|
||||||
|
end
|
||||||
|
return "other";
|
||||||
|
end
|
||||||
|
|
||||||
|
local function string_replace(str, match, replace)
|
||||||
|
local s, e = string.find(str, match, 1, true)
|
||||||
|
if s == nil or e == nil then
|
||||||
|
return str
|
||||||
|
end
|
||||||
|
return string.sub(str, 1, s - 1) .. replace .. string.sub(str, e + 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- https://github.com/mpv-player/mpv/blob/master/DOCS/edl-mpv.rst
|
||||||
|
local function generate_m3u(magnet_uri, files)
|
||||||
|
for _, fileinfo in ipairs(files) do
|
||||||
|
-- strip top directory
|
||||||
|
if fileinfo.path:find("/", 1, true) then
|
||||||
|
fileinfo.fullpath = string.sub(fileinfo.path, fileinfo.path:find("/", 1, true) + 1)
|
||||||
|
else
|
||||||
|
fileinfo.fullpath = fileinfo.path
|
||||||
|
end
|
||||||
|
fileinfo.path = {}
|
||||||
|
for w in fileinfo.fullpath:gmatch("([^/]+)") do table.insert(fileinfo.path, w) end
|
||||||
|
local ext = string.match(fileinfo.path[#fileinfo.path], "%.(%w+)$")
|
||||||
|
fileinfo.type = guess_type_by_extension(ext)
|
||||||
|
end
|
||||||
|
table.sort(files, function(a, b)
|
||||||
|
-- make top-level files appear first in the playlist
|
||||||
|
if (#a.path == 1 or #b.path == 1) and #a.path ~= #b.path then
|
||||||
|
return #a.path < #b.path
|
||||||
|
end
|
||||||
|
-- make videos first
|
||||||
|
if (a.type == "video" or b.type == "video") and a.type ~= b.type then
|
||||||
|
return a.type == "video"
|
||||||
|
end
|
||||||
|
-- otherwise sort by path
|
||||||
|
return a.fullpath < b.fullpath
|
||||||
|
end);
|
||||||
|
|
||||||
|
local infohash = magnet_uri:match("^magnet:%?xt=urn:bt[im]h:(%w+)") or urlencode(magnet_uri)
|
||||||
|
|
||||||
|
local playlist = { '#EXTM3U' }
|
||||||
|
|
||||||
|
for _, fileinfo in ipairs(files) do
|
||||||
|
if fileinfo.processed ~= true then
|
||||||
|
table.insert(playlist, '#EXTINF:0,' .. fileinfo.fullpath)
|
||||||
|
local basename = string.match(fileinfo.path[#fileinfo.path], '^(.+)%.%w+$')
|
||||||
|
|
||||||
|
local url = opts.server .. "/stream/" .. urlencode(fileinfo.fullpath) .."?play&index=" .. fileinfo.id .. "&link=" .. infohash
|
||||||
|
local hdr = { "!new_stream", "!no_clip",
|
||||||
|
--"!track_meta,title=" .. edlencode(basename),
|
||||||
|
edlencode(url)
|
||||||
|
}
|
||||||
|
local edl = "edl://" .. table.concat(hdr, ";") .. ";"
|
||||||
|
local external_tracks = 0
|
||||||
|
|
||||||
|
fileinfo.processed = true
|
||||||
|
if opts.search_for_external_tracks and basename ~= nil and fileinfo.type == "video" then
|
||||||
|
mp.msg.info("!" .. basename)
|
||||||
|
|
||||||
|
for _, fileinfo2 in ipairs(files) do
|
||||||
|
if #fileinfo2.path > 0 and
|
||||||
|
fileinfo2.type ~= "other" and
|
||||||
|
fileinfo2.processed ~= true and
|
||||||
|
string.find(fileinfo2.path[#fileinfo2.path], basename, 1, true) ~= nil
|
||||||
|
then
|
||||||
|
mp.msg.info("->" .. fileinfo2.fullpath)
|
||||||
|
local title = string_replace(fileinfo2.fullpath, basename, "%")
|
||||||
|
local url = opts.server .. "/stream/" .. urlencode(fileinfo2.fullpath).."?play&index=" .. fileinfo2.id .. "&link=" .. infohash
|
||||||
|
local hdr = { "!new_stream", "!no_clip", "!no_chapters",
|
||||||
|
"!delay_open,media_type=" .. fileinfo2.type,
|
||||||
|
"!track_meta,title=" .. edlencode(title),
|
||||||
|
edlencode(url)
|
||||||
|
}
|
||||||
|
edl = edl .. table.concat(hdr, ";") .. ";"
|
||||||
|
fileinfo2.processed = true
|
||||||
|
external_tracks = external_tracks + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if external_tracks == 0 then -- dont use edl
|
||||||
|
table.insert(playlist, url)
|
||||||
|
else
|
||||||
|
table.insert(playlist, edl)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return table.concat(playlist, '\n')
|
||||||
|
end
|
||||||
|
|
||||||
|
mp.add_hook("on_load", 5, function()
|
||||||
|
local url = mp.get_property("stream-open-filename")
|
||||||
|
if url:find("^magnet:") == 1 or (url:find("^https?://") == 1 and url:find("%.torrent$") ~= nil) then
|
||||||
|
mp.set_property_bool("file-local-options/ytdl", false)
|
||||||
|
if opts.torrserver_init then init() end
|
||||||
|
local magnet_info, err = get_magnet_info(url)
|
||||||
|
if type(magnet_info) == "table" then
|
||||||
|
if magnet_info.file_stats then
|
||||||
|
-- torrent has multiple files. open as playlist
|
||||||
|
mp.set_property("stream-open-filename", "memory://" .. generate_m3u(url, magnet_info.file_stats))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- if not a playlist and has a name
|
||||||
|
if magnet_info.name then
|
||||||
|
mp.set_property("stream-open-filename", "memory://#EXTM3U\n" ..
|
||||||
|
"#EXTINF:0," .. magnet_info.name .. "\n" ..
|
||||||
|
opts.server .. "/stream?play&index=1&link=" .. urlencode(url))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
else
|
||||||
|
mp.msg.warn("error: " .. err)
|
||||||
|
end
|
||||||
|
mp.set_property("stream-open-filename", opts.server .. "/stream?m3u&link=" .. urlencode(url))
|
||||||
|
end
|
||||||
|
end)
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
-- original by Wanakachi
|
||||||
|
-- suitable for Windows operating systems
|
||||||
|
|
||||||
|
-- picture-in-picture (PiP) mode for mpv
|
||||||
|
|
||||||
|
local options = require "mp.options"
|
||||||
|
|
||||||
|
local o = {
|
||||||
|
autofit = "25%x25%",
|
||||||
|
autofit_restore_larger = "100%x100%",
|
||||||
|
autofit_restore_smaller = "40%x30%",
|
||||||
|
geometry = "100%:100%", -- pip position, bottom-right: 100%:100%
|
||||||
|
geometry_restore = "50%:50%", -- restore position, center: 50%:50%
|
||||||
|
geometry_delay = 0.05 -- delay to avoid geometry race on state change
|
||||||
|
}
|
||||||
|
|
||||||
|
options.read_options(o, _, function() end)
|
||||||
|
|
||||||
|
local pip_enabled = false
|
||||||
|
local geometry_timer = nil
|
||||||
|
local original_options = {}
|
||||||
|
|
||||||
|
-- save original window properties
|
||||||
|
local function save_original_options()
|
||||||
|
original_options.fullscreen = mp.get_property_bool("fullscreen")
|
||||||
|
original_options.window_maximized = mp.get_property_bool("window-maximized")
|
||||||
|
original_options.force_window_position = mp.get_property_bool("force-window-position")
|
||||||
|
original_options.keepaspect_window = mp.get_property_bool("keepaspect-window")
|
||||||
|
original_options.border = mp.get_property_bool("border")
|
||||||
|
original_options.ontop = mp.get_property_bool("ontop")
|
||||||
|
original_options.window_scale = mp.get_property("window_scale")
|
||||||
|
original_options.autofit = mp.get_property("autofit")
|
||||||
|
original_options.autofit_larger = mp.get_property("autofit-larger") ~= "" and mp.get_property("autofit-larger") or o.autofit_restore_larger
|
||||||
|
original_options.autofit_smaller = mp.get_property("autofit-smaller") ~= "" and mp.get_property("autofit-smaller") or o.autofit_restore_smaller
|
||||||
|
original_options.geometry = mp.get_property("geometry") ~= "" and mp.get_property("geometry") or o.geometry_restore
|
||||||
|
end
|
||||||
|
|
||||||
|
-- restore original window properties
|
||||||
|
local function restore_original_options()
|
||||||
|
if original_options.fullscreen ~= nil then mp.set_property_bool("fullscreen", original_options.fullscreen) end
|
||||||
|
if original_options.window_maximized ~= nil then mp.set_property_bool("window-maximized", original_options.window_maximized) end
|
||||||
|
if original_options.force_window_position ~= nil then mp.set_property_bool("force-window-position", original_options.force_window_position) end
|
||||||
|
if original_options.border ~= nil then mp.set_property_bool("border", original_options.border) end
|
||||||
|
if original_options.ontop ~= nil then mp.set_property_bool("ontop", original_options.ontop) end
|
||||||
|
if original_options.keepaspect_window ~= nil then mp.set_property_bool("keepaspect-window", original_options.keepaspect_window) end
|
||||||
|
if original_options.window_scale ~= nil then mp.set_property("window_scale", original_options.window_scale) end
|
||||||
|
if original_options.autofit ~= nil then mp.set_property("autofit", original_options.autofit) end
|
||||||
|
if original_options.autofit_larger ~= nil then mp.set_property("autofit-larger", original_options.autofit_larger) end
|
||||||
|
if original_options.autofit_smaller ~= nil then mp.set_property("autofit-smaller", original_options.autofit_smaller) end
|
||||||
|
if original_options.geometry ~= nil then mp.set_property("geometry", original_options.geometry) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function cancel_geometry_timer()
|
||||||
|
if geometry_timer then
|
||||||
|
geometry_timer:kill()
|
||||||
|
geometry_timer = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function set_geometry_delay(value)
|
||||||
|
cancel_geometry_timer()
|
||||||
|
geometry_timer = mp.add_timeout(o.geometry_delay, function()
|
||||||
|
geometry_timer = nil
|
||||||
|
mp.set_property("geometry", value)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- enter PiP mode
|
||||||
|
local function enable_pip()
|
||||||
|
if pip_enabled then return end
|
||||||
|
|
||||||
|
save_original_options()
|
||||||
|
|
||||||
|
mp.set_property_bool("fullscreen", false) -- exit fullscreen if necessary
|
||||||
|
mp.set_property_bool("window-minimized", false)
|
||||||
|
mp.set_property_bool("force-window-position", false)
|
||||||
|
mp.set_property_bool("border", false) -- remove decorations
|
||||||
|
mp.set_property_bool("ontop", true) -- keep above other windows
|
||||||
|
mp.set_property_bool("keepaspect-window", true)
|
||||||
|
mp.set_property("autofit", o.autofit)
|
||||||
|
mp.set_property("autofit-larger", "")
|
||||||
|
mp.set_property("autofit-smaller", "")
|
||||||
|
mp.set_property("window_scale", "")
|
||||||
|
set_geometry_delay(o.geometry)
|
||||||
|
|
||||||
|
pip_enabled = true
|
||||||
|
mp.osd_message("PiP enabled")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- leave PiP mode
|
||||||
|
local function disable_pip()
|
||||||
|
if not pip_enabled then return end
|
||||||
|
|
||||||
|
cancel_geometry_timer()
|
||||||
|
restore_original_options()
|
||||||
|
pip_enabled = false
|
||||||
|
mp.osd_message("PiP disabled")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- toggle PiP on/off
|
||||||
|
local function toggle_pip()
|
||||||
|
if pip_enabled then
|
||||||
|
disable_pip()
|
||||||
|
else
|
||||||
|
enable_pip()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- monitor fullscreen state changes and exit PiP first
|
||||||
|
mp.observe_property("fullscreen", "bool", function(_, value)
|
||||||
|
if value and pip_enabled then
|
||||||
|
disable_pip()
|
||||||
|
mp.set_property_bool("fullscreen", true)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- monitor window-maximized state changes (on some systems maximize and fullscreen are separate)
|
||||||
|
mp.observe_property("window-maximized", "bool", function(_, value)
|
||||||
|
if value and pip_enabled then
|
||||||
|
disable_pip()
|
||||||
|
mp.set_property_bool("window-maximized", true)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- binding
|
||||||
|
mp.add_key_binding(nil, "toggle-pip", toggle_pip)
|
||||||
@@ -54,12 +54,13 @@ local function url_decode(str)
|
|||||||
if str:find('://localhost:?') then
|
if str:find('://localhost:?') then
|
||||||
str = str:gsub('^.*/', '')
|
str = str:gsub('^.*/', '')
|
||||||
end
|
end
|
||||||
str = str:gsub('%?.+', '')
|
str = str:gsub("%?.+", ""):gsub("%+", " ")
|
||||||
:gsub('%+', ' ')
|
local last_pos = str:match('.*[\\/:%?]()')
|
||||||
return str
|
if last_pos then
|
||||||
else
|
str = str:sub(last_pos)
|
||||||
return
|
end
|
||||||
end
|
end
|
||||||
|
return str
|
||||||
end
|
end
|
||||||
|
|
||||||
local function is_compressed_file(filename)
|
local function is_compressed_file(filename)
|
||||||
|
|||||||
+176
-74
@@ -1,10 +1,13 @@
|
|||||||
-- trackselect.lua
|
--[[
|
||||||
-- https://github.com/po5/trackselect
|
SOURCE_: https://github.com/po5/trackselect
|
||||||
-- Because --slang isn't smart enough.
|
Modify_: https://github.com/dyphire/mpv-scripts
|
||||||
--
|
|
||||||
-- This script tries to select non-dub
|
-- Because --slang isn't smart enough.
|
||||||
-- audio and subtitle tracks.
|
--
|
||||||
-- Idea from https://github.com/siikamiika/scripts/blob/master/mpv%20scripts/dualaudiofix.lua
|
-- This script tries to select non-dub
|
||||||
|
-- audio and subtitle tracks.
|
||||||
|
-- Idea from https://github.com/siikamiika/scripts/blob/master/mpv%20scripts/dualaudiofix.lua
|
||||||
|
]]
|
||||||
|
|
||||||
local opt = require 'mp.options'
|
local opt = require 'mp.options'
|
||||||
local utils = require 'mp.utils'
|
local utils = require 'mp.utils'
|
||||||
@@ -14,9 +17,10 @@ local defaults = {
|
|||||||
selected = nil,
|
selected = nil,
|
||||||
best = {},
|
best = {},
|
||||||
lang_score = nil,
|
lang_score = nil,
|
||||||
|
expected_score = nil,
|
||||||
channels_score = -math.huge,
|
channels_score = -math.huge,
|
||||||
preferred = "jpn/japanese",
|
preferred = "jpn/japanese",
|
||||||
excluded = "",
|
excluded = "commentary/cast/staff/dub/guide",
|
||||||
expected = "",
|
expected = "",
|
||||||
id = ""
|
id = ""
|
||||||
},
|
},
|
||||||
@@ -24,6 +28,7 @@ local defaults = {
|
|||||||
selected = nil,
|
selected = nil,
|
||||||
best = {},
|
best = {},
|
||||||
lang_score = nil,
|
lang_score = nil,
|
||||||
|
expected_score = nil,
|
||||||
preferred = "",
|
preferred = "",
|
||||||
excluded = "",
|
excluded = "",
|
||||||
expected = "",
|
expected = "",
|
||||||
@@ -33,8 +38,9 @@ local defaults = {
|
|||||||
selected = nil,
|
selected = nil,
|
||||||
best = {},
|
best = {},
|
||||||
lang_score = nil,
|
lang_score = nil,
|
||||||
|
expected_score = nil,
|
||||||
preferred = "eng",
|
preferred = "eng",
|
||||||
excluded = "sign",
|
excluded = "sign/song",
|
||||||
expected = "",
|
expected = "",
|
||||||
id = ""
|
id = ""
|
||||||
}
|
}
|
||||||
@@ -54,11 +60,6 @@ local options = {
|
|||||||
|
|
||||||
-- Try to re-select the last track if mpv cannot do it e.g. when fingerprint changes
|
-- Try to re-select the last track if mpv cannot do it e.g. when fingerprint changes
|
||||||
smart_keep = false,
|
smart_keep = false,
|
||||||
|
|
||||||
--add above (after a comma) any protocol to disable
|
|
||||||
special_protocols = [[
|
|
||||||
["://", "^magnet:"]
|
|
||||||
]],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _type, track in pairs(defaults) do
|
for _type, track in pairs(defaults) do
|
||||||
@@ -72,41 +73,82 @@ options["preferred_audio_channels"] = ""
|
|||||||
local tracks = {}
|
local tracks = {}
|
||||||
local last = {}
|
local last = {}
|
||||||
local fingerprint = ""
|
local fingerprint = ""
|
||||||
|
local trackselect_ran = false
|
||||||
|
|
||||||
opt.read_options(options, _, function() end)
|
opt.read_options(options, _, function() end)
|
||||||
|
|
||||||
options.special_protocols = utils.parse_json(options.special_protocols)
|
local function is_protocol(path)
|
||||||
|
return type(path) == 'string' and (path:find('^%a[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= nil)
|
||||||
|
end
|
||||||
|
|
||||||
local function need_ignore(tab, val)
|
local function hex_to_char(x)
|
||||||
for index, element in ipairs(tab) do
|
return string.char(tonumber(x, 16))
|
||||||
if string.find(val, element) then
|
end
|
||||||
return true
|
|
||||||
|
local function url_decode(str)
|
||||||
|
if str ~= nil then
|
||||||
|
str = str:gsub('^%a[%a%d-_]+://', '')
|
||||||
|
:gsub('^%a[%a%d-_]+:\\?', '')
|
||||||
|
:gsub('%%(%x%x)', hex_to_char)
|
||||||
|
if str:find('://localhost:?') then
|
||||||
|
str = str:gsub('^.*/', '')
|
||||||
end
|
end
|
||||||
|
str = str:gsub("%?.+", ""):gsub("%+", " ")
|
||||||
|
local last_pos = str:match('.*[\\/:%?]()')
|
||||||
|
if last_pos then
|
||||||
|
str = str:sub(last_pos)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return str
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_empty(input)
|
||||||
|
if input == nil or input == "" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function replace(str, what, with)
|
||||||
|
if is_empty(str) then return "" end
|
||||||
|
if is_empty(what) then return str end
|
||||||
|
if with == nil then with = "" end
|
||||||
|
what = string.gsub(what, "[%(%)%.%+%-%*%?%[%]%^%$%%]", "%%%1")
|
||||||
|
with = string.gsub(with, "[%%]", "%%%%")
|
||||||
|
return string.gsub(str, what, with)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function comparison(str1, str2)
|
||||||
|
if not str1 and not str2 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if str1 and str2 then
|
||||||
|
return str1:lower() == str2:lower()
|
||||||
end
|
end
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
function contains(track, words, attr)
|
local function contains(track, words, attr)
|
||||||
if not track[attr] then return false end
|
if not track[attr] then return false end
|
||||||
local i = 0
|
local i = 0
|
||||||
if track.external then
|
if track.external then
|
||||||
i = 1
|
i = 1
|
||||||
end
|
end
|
||||||
for word in string.gmatch(words:lower(), "([^/]+)") do
|
for word in string.gmatch(words:lower(), "([^/]+)") do
|
||||||
|
local w = word:match("^%s*(.-)%s*$")
|
||||||
i = i - 1
|
i = i - 1
|
||||||
if string.find(tostring(track[attr] or ""):lower(), word) then
|
if w ~= "" and string.find(tostring(track[attr] or ""):lower(), w) then
|
||||||
return i
|
return i
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
function preferred(track, words, attr, title)
|
local function preferred(track, words, attr, title)
|
||||||
local score = contains(track, words, attr)
|
local score = contains(track, words, attr)
|
||||||
if not score then
|
if not score then
|
||||||
if tracks[track.type][title] == nil then
|
if tracks[track.type][title] == nil then
|
||||||
tracks[track.type][title] = -math.huge
|
tracks[track.type][title] = -math.huge
|
||||||
return true
|
return false
|
||||||
end
|
end
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
@@ -117,10 +159,11 @@ function preferred(track, words, attr, title)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
function preferred_or_equals(track, words, attr, title)
|
local function preferred_or_equals(track, words, attr, title)
|
||||||
local score = contains(track, words, attr)
|
local score = contains(track, words, attr)
|
||||||
if not score then
|
if not score then
|
||||||
if tracks[track.type][title] == nil or tracks[track.type][title] == -math.huge then
|
if (tracks[track.type][title] == nil or
|
||||||
|
tracks[track.type][title] == -math.huge) and track.default then
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
return false
|
return false
|
||||||
@@ -131,14 +174,14 @@ function preferred_or_equals(track, words, attr, title)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
function copy(obj)
|
local function copy(obj)
|
||||||
if type(obj) ~= "table" then return obj end
|
if type(obj) ~= "table" then return obj end
|
||||||
local res = {}
|
local res = {}
|
||||||
for k, v in pairs(obj) do res[k] = copy(v) end
|
for k, v in pairs(obj) do res[k] = copy(v) end
|
||||||
return res
|
return res
|
||||||
end
|
end
|
||||||
|
|
||||||
function track_layout_hash(tracklist)
|
local function track_layout_hash(tracklist)
|
||||||
local t = {}
|
local t = {}
|
||||||
for _, track in ipairs(tracklist) do
|
for _, track in ipairs(tracklist) do
|
||||||
t[#t + 1] = string.format("%s-%d-%s-%s-%s-%s", track.type, track.id, tostring(track.default),
|
t[#t + 1] = string.format("%s-%d-%s-%s-%s-%s", track.type, track.id, tostring(track.default),
|
||||||
@@ -147,12 +190,51 @@ function track_layout_hash(tracklist)
|
|||||||
return table.concat(t, "\n")
|
return table.concat(t, "\n")
|
||||||
end
|
end
|
||||||
|
|
||||||
function trackselect()
|
local function sanitize_title(track, path, filename, media_title)
|
||||||
local fpath = mp.get_property('path')
|
if not track or not track.title then return end
|
||||||
if not options.enabled then return end
|
local title = filename
|
||||||
if need_ignore(options.special_protocols, fpath) then return end
|
if is_protocol(path) then
|
||||||
tracks = copy(defaults)
|
title = url_decode(media_title):gsub("%.?([^%.]+)$", "")
|
||||||
|
track.title = url_decode(track.title)
|
||||||
|
end
|
||||||
|
track.title = replace(track.title, title, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function selected_tracks()
|
||||||
|
if not trackselect_ran then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
last = {}
|
||||||
|
local tracklist = mp.get_property_native("track-list")
|
||||||
|
local path = mp.get_property("path")
|
||||||
local filename = mp.get_property("filename/no-ext")
|
local filename = mp.get_property("filename/no-ext")
|
||||||
|
local media_title = mp.get_property("media-title", "")
|
||||||
|
for _, track in ipairs(tracklist) do
|
||||||
|
if track.selected then
|
||||||
|
sanitize_title(track, path, filename, media_title)
|
||||||
|
last[track.type] = track
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function trackselect()
|
||||||
|
if options.smart_keep then
|
||||||
|
-- observe specific current-tracks sub-properties
|
||||||
|
local props = { 'current-tracks/video', 'current-tracks/audio', 'current-tracks/sub' }
|
||||||
|
for _, p in ipairs(props) do
|
||||||
|
mp.observe_property(p, 'native', selected_tracks)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
trackselect_ran = true
|
||||||
|
if not options.enabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
tracks = copy(defaults)
|
||||||
|
local path = mp.get_property("path")
|
||||||
|
local filename = mp.get_property("filename/no-ext")
|
||||||
|
local media_title = mp.get_property("media-title", "")
|
||||||
local tracklist = mp.get_property_native("track-list")
|
local tracklist = mp.get_property_native("track-list")
|
||||||
local tracklist_changed = false
|
local tracklist_changed = false
|
||||||
local found_last = {}
|
local found_last = {}
|
||||||
@@ -164,10 +246,14 @@ function trackselect()
|
|||||||
fingerprint = new_fingerprint
|
fingerprint = new_fingerprint
|
||||||
tracklist_changed = true
|
tracklist_changed = true
|
||||||
end
|
end
|
||||||
|
|
||||||
for _, track in ipairs(tracklist) do
|
for _, track in ipairs(tracklist) do
|
||||||
if options.smart_keep and last[track.type] ~= nil and last[track.type].lang == track.lang and
|
sanitize_title(track, path, filename, media_title)
|
||||||
last[track.type].codec == track.codec and last[track.type].external == track.external and
|
if options.smart_keep and last[track.type] ~= nil and
|
||||||
last[track.type].title == track.title then
|
comparison(last[track.type].lang, track.lang) and
|
||||||
|
track.codec ~= "null" and last[track.type].codec == track.codec and
|
||||||
|
last[track.type].external == track.external and
|
||||||
|
comparison(last[track.type].title, track.title) then
|
||||||
tracks[track.type].best = track
|
tracks[track.type].best = track
|
||||||
options["preferred_" .. track.type .. "_lang"] = ""
|
options["preferred_" .. track.type .. "_lang"] = ""
|
||||||
options["excluded_" .. track.type .. "_words"] = ""
|
options["excluded_" .. track.type .. "_words"] = ""
|
||||||
@@ -185,40 +271,64 @@ function trackselect()
|
|||||||
options["preferred_" .. track.type .. "_channels"] = ""
|
options["preferred_" .. track.type .. "_channels"] = ""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if options["preferred_" .. track.type .. "_lang"] ~= "" or options["excluded_" .. track.type .. "_words"] ~= ""
|
if track.codec ~= "null" and options["preferred_" .. track.type .. "_lang"] ~= "" or
|
||||||
or options["expected_" .. track.type .. "_words"] ~= "" or
|
options["excluded_" .. track.type .. "_words"] ~= "" or
|
||||||
|
options["expected_" .. track.type .. "_words"] ~= "" or
|
||||||
(options["preferred_" .. track.type .. "_channels"] or "") ~= "" then
|
(options["preferred_" .. track.type .. "_channels"] or "") ~= "" then
|
||||||
|
local preferred_lang = options["preferred_" .. track.type .. "_lang"]
|
||||||
|
local excluded_words = options["excluded_" .. track.type .. "_words"]
|
||||||
|
local expected_words = options["expected_" .. track.type .. "_words"]
|
||||||
|
local preferred_channels = options["preferred_" .. track.type .. "_channels"]
|
||||||
|
|
||||||
|
if preferred_lang == "" then
|
||||||
|
if track.type == "video" then
|
||||||
|
preferred_lang = mp.get_property("vlang", ""):gsub(",", "/")
|
||||||
|
elseif track.type == "audio" then
|
||||||
|
preferred_lang = mp.get_property("alang", ""):gsub(",", "/")
|
||||||
|
elseif track.type == "sub" then
|
||||||
|
preferred_lang = mp.get_property("slang", ""):gsub(",", "/")
|
||||||
|
end
|
||||||
|
end
|
||||||
if track.selected then
|
if track.selected then
|
||||||
tracks[track.type].selected = track.id
|
tracks[track.type].selected = track.id
|
||||||
if options.smart_keep then
|
if options.smart_keep then
|
||||||
last[track.type] = track
|
last[track.type] = track
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if track.title then
|
|
||||||
track.title = string.gsub(string.gsub(track.title, "[%(%)%.%+%-%*%?%[%]%^%$%%]", "%%%1"), filename, "")
|
if (next(tracks[track.type].best) == nil or not (tracks[track.type].best.external
|
||||||
end
|
and tracks[track.type].best.lang ~= nil and not track.external)) then
|
||||||
if next(tracks[track.type].best) == nil or not (tracks[track.type].best.external
|
if excluded_words == "" or not contains(track, excluded_words, "title") then
|
||||||
and tracks[track.type].best.lang ~= nil and not track.external) then
|
local channels = false
|
||||||
if options["excluded_" .. track.type .. "_words"] == "" or
|
local lang = false
|
||||||
not contains(track, options["excluded_" .. track.type .. "_words"], "title") then
|
if (preferred_channels or "") ~= "" and
|
||||||
if options["expected_" .. track.type .. "_words"] == "" or
|
preferred_or_equals(track, preferred_lang, "lang", "lang_score") then
|
||||||
contains(track, options["expected_" .. track.type .. "_words"], "title") then
|
channels = preferred(track, preferred_channels, "demux-channel-count", "channels_score")
|
||||||
local pass = true
|
end
|
||||||
local channels = false
|
if preferred_lang ~= "" then
|
||||||
local lang = false
|
lang = preferred(track, preferred_lang, "lang", "lang_score")
|
||||||
if (options["preferred_" .. track.type .. "_channels"] or "") ~= "" and
|
end
|
||||||
preferred_or_equals(track, options["preferred_" .. track.type .. "_lang"], "lang",
|
|
||||||
"lang_score") then
|
local exp_score = nil
|
||||||
channels = preferred(track, options["preferred_" .. track.type .. "_channels"],
|
if expected_words then
|
||||||
"demux-channel-count", "channels_score")
|
exp_score = contains(track, expected_words, "title")
|
||||||
pass = channels
|
end
|
||||||
|
if exp_score then
|
||||||
|
local lang_score = nil
|
||||||
|
local existing_lang = nil
|
||||||
|
local existing_exp = tracks[track.type].expected_score
|
||||||
|
if next(tracks[track.type].best) ~= nil and preferred_lang ~= "" then
|
||||||
|
existing_lang = contains(tracks[track.type].best, preferred_lang, "lang")
|
||||||
|
lang_score = contains(track, preferred_lang, "lang")
|
||||||
end
|
end
|
||||||
if options["preferred_" .. track.type .. "_lang"] ~= "" then
|
if existing_exp == nil or exp_score > existing_exp or (exp_score == existing_exp and
|
||||||
lang = preferred(track, options["preferred_" .. track.type .. "_lang"], "lang", "lang_score")
|
((lang_score or -math.huge) > (existing_lang or -math.huge) or channels)) then
|
||||||
|
tracks[track.type].expected_score = exp_score
|
||||||
|
tracks[track.type].best = track
|
||||||
end
|
end
|
||||||
if (options["preferred_" .. track.type .. "_lang"] == "" and pass) or channels or lang or
|
elseif tracks[track.type].expected_score == nil then
|
||||||
(track.external and track.lang == nil and
|
if (preferred_lang == "" and track.default) or channels or lang or
|
||||||
(not tracks[track.type].best.external or tracks[track.type].best.lang == nil)) then
|
(track.external and track.lang == nil and next(tracks[track.type].best) == nil) then
|
||||||
tracks[track.type].best = track
|
tracks[track.type].best = track
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -226,9 +336,10 @@ function trackselect()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
for _type, track in pairs(tracks) do
|
for _type, track in pairs(tracks) do
|
||||||
if next(track.best) ~= nil and track.best.id ~= track.selected then
|
if next(track.best) ~= nil and track.best.id ~= track.selected then
|
||||||
mp.set_property(_type:sub(1, 1) .. "id", track.best.id)
|
mp.set_property('file-local-options/'.. _type:sub(1, 1) .. "id", track.best.id)
|
||||||
if options.smart_keep and found_last[track.best.type] then
|
if options.smart_keep and found_last[track.best.type] then
|
||||||
last[track.best.type] = track.best
|
last[track.best.type] = track.best
|
||||||
end
|
end
|
||||||
@@ -236,22 +347,13 @@ function trackselect()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function selected_tracks()
|
|
||||||
local tracklist = mp.get_property_native("track-list")
|
|
||||||
last = {}
|
|
||||||
for _, track in ipairs(tracklist) do
|
|
||||||
if track.selected then
|
|
||||||
last[track.type] = track
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if options.hook then
|
if options.hook then
|
||||||
mp.add_hook("on_preloaded", 50, trackselect)
|
mp.add_hook("on_preloaded", 50, trackselect)
|
||||||
else
|
else
|
||||||
mp.register_event("file-loaded", trackselect)
|
mp.register_event("file-loaded", trackselect)
|
||||||
end
|
end
|
||||||
|
|
||||||
if options.smart_keep then
|
mp.add_hook("on_unload", 50, function()
|
||||||
mp.register_event("track-switched", selected_tracks)
|
trackselect_ran = false
|
||||||
end
|
mp.unobserve_property(selected_tracks)
|
||||||
|
end)
|
||||||
@@ -36,7 +36,7 @@ end
|
|||||||
function Button:render()
|
function Button:render()
|
||||||
local visibility = self:get_visibility()
|
local visibility = self:get_visibility()
|
||||||
if visibility <= 0 then return end
|
if visibility <= 0 then return end
|
||||||
cursor:zone('primary_down', self, function() self:handle_cursor_click() end)
|
cursor:zone('primary_click', self, function() self:handle_cursor_click() end)
|
||||||
|
|
||||||
local ass = assdraw.ass_new()
|
local ass = assdraw.ass_new()
|
||||||
local is_clickable = self.is_clickable and self.on_click ~= nil
|
local is_clickable = self.is_clickable and self.on_click ~= nil
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ function Menu:init(data, callback, opts)
|
|||||||
self.opts = opts or {}
|
self.opts = opts or {}
|
||||||
self.offset_x = 0 -- Used for submenu transition animation.
|
self.offset_x = 0 -- Used for submenu transition animation.
|
||||||
self.mouse_nav = self.opts.mouse_nav -- Stops pre-selecting items
|
self.mouse_nav = self.opts.mouse_nav -- Stops pre-selecting items
|
||||||
|
self.mouse_hovered_index = nil -- Current menu's item being hovered by mouse, ignoring it's selectable or not.
|
||||||
self.item_height = nil
|
self.item_height = nil
|
||||||
self.min_width = nil
|
self.min_width = nil
|
||||||
self.item_spacing = 1
|
self.item_spacing = 1
|
||||||
@@ -700,7 +701,8 @@ end
|
|||||||
|
|
||||||
---@param shortcut? Shortcut
|
---@param shortcut? Shortcut
|
||||||
function Menu:handle_cursor_up(shortcut)
|
function Menu:handle_cursor_up(shortcut)
|
||||||
if self.proximity_raw <= -self.padding and self.drag_last_y and not self.is_dragging then
|
if self.proximity_raw <= -self.padding and self.drag_last_y and not self.is_dragging
|
||||||
|
and self.mouse_hovered_index == self.current.selected_index then
|
||||||
self:activate_selected_item(shortcut, true)
|
self:activate_selected_item(shortcut, true)
|
||||||
end
|
end
|
||||||
if self.is_dragging then
|
if self.is_dragging then
|
||||||
@@ -1456,14 +1458,16 @@ function Menu:render()
|
|||||||
}
|
}
|
||||||
|
|
||||||
-- Select hovered item
|
-- Select hovered item
|
||||||
if is_current and self.mouse_nav and item.selectable ~= false
|
if is_current and self.mouse_nav
|
||||||
-- Do not select items if cursor is moving towards a submenu
|
|
||||||
and (not submenu_rect or not cursor:direction_to_rectangle_distance(submenu_rect))
|
|
||||||
and (submenu_is_hovered or get_point_to_rectangle_proximity(cursor, item_rect_hitbox) <= 0) then
|
and (submenu_is_hovered or get_point_to_rectangle_proximity(cursor, item_rect_hitbox) <= 0) then
|
||||||
menu.selected_index = index
|
self.mouse_hovered_index = index
|
||||||
if not is_selected then
|
-- Do not select items if cursor is moving towards a submenu
|
||||||
is_selected = true
|
if item.selectable ~= false and (not submenu_rect or not cursor:direction_to_rectangle_distance(submenu_rect)) then
|
||||||
request_render()
|
menu.selected_index = index
|
||||||
|
if not is_selected then
|
||||||
|
is_selected = true
|
||||||
|
request_render()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1555,7 +1559,7 @@ function Menu:render()
|
|||||||
|
|
||||||
-- Select action on cursor hover
|
-- Select action on cursor hover
|
||||||
if self.mouse_nav and get_point_to_rectangle_proximity(cursor, rect) <= 0 then
|
if self.mouse_nav and get_point_to_rectangle_proximity(cursor, rect) <= 0 then
|
||||||
cursor:zone('primary_down', rect, self:create_action(function(shortcut)
|
cursor:zone('primary_click', rect, self:create_action(function(shortcut)
|
||||||
self:activate_selected_item(shortcut, true)
|
self:activate_selected_item(shortcut, true)
|
||||||
end))
|
end))
|
||||||
blur_action_index = false
|
blur_action_index = false
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ function TopBar:render()
|
|||||||
local button_fg = is_hover and (button.hover_fg or bg) or fg
|
local button_fg = is_hover and (button.hover_fg or bg) or fg
|
||||||
local button_bg = is_hover and (button.hover_bg or fg) or bg
|
local button_bg = is_hover and (button.hover_bg or fg) or bg
|
||||||
|
|
||||||
cursor:zone('primary_down', rect, button.command)
|
cursor:zone('primary_click', rect, button.command)
|
||||||
|
|
||||||
local bg_size = self.size - margin
|
local bg_size = self.size - margin
|
||||||
local bg_ax, bg_ay = rect.ax + (is_left and margin or 0), rect.ay + margin
|
local bg_ax, bg_ay = rect.ax + (is_left and margin or 0), rect.ay + margin
|
||||||
@@ -302,7 +302,7 @@ function TopBar:render()
|
|||||||
if left_aligned then title_bx = rect.ax - margin else title_ax = rect.bx + margin end
|
if left_aligned then title_bx = rect.ax - margin else title_ax = rect.bx + margin end
|
||||||
|
|
||||||
-- Click action
|
-- Click action
|
||||||
cursor:zone('primary_down', rect, function() mp.command('script-binding uosc/playlist') end)
|
cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/playlist') end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Skip rendering titles if there's not enough horizontal space
|
-- Skip rendering titles if there's not enough horizontal space
|
||||||
@@ -325,7 +325,7 @@ function TopBar:render()
|
|||||||
local title_rect = {ax = ax, ay = title_ay, bx = ax + rect_width, by = by}
|
local title_rect = {ax = ax, ay = title_ay, bx = ax + rect_width, by = by}
|
||||||
|
|
||||||
if options.top_bar_alt_title_place == 'toggle' then
|
if options.top_bar_alt_title_place == 'toggle' then
|
||||||
cursor:zone('primary_down', title_rect, function() self:toggle_title() end)
|
cursor:zone('primary_click', title_rect, function() self:toggle_title() end)
|
||||||
end
|
end
|
||||||
|
|
||||||
ass:rect(title_rect.ax, title_rect.ay, title_rect.bx, title_rect.by, {
|
ass:rect(title_rect.ax, title_rect.ay, title_rect.bx, title_rect.by, {
|
||||||
@@ -415,7 +415,7 @@ function TopBar:render()
|
|||||||
|
|
||||||
-- Click action
|
-- Click action
|
||||||
rect.bx = time_bx
|
rect.bx = time_bx
|
||||||
cursor:zone('primary_down', rect, function() mp.command('script-binding uosc/chapters') end)
|
cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/chapters') end)
|
||||||
|
|
||||||
title_ay = rect.by + self.title_spacing
|
title_ay = rect.by + self.title_spacing
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ local cursor = {
|
|||||||
x = math.huge,
|
x = math.huge,
|
||||||
y = math.huge,
|
y = math.huge,
|
||||||
hidden = true,
|
hidden = true,
|
||||||
|
disabled = false,
|
||||||
|
disablers = {}, -- List of ids. When not empty, the cursor handling is disabled.
|
||||||
distance = 0, -- Distance traveled during current move. Reset by `cursor.distance_reset_timer`.
|
distance = 0, -- Distance traveled during current move. Reset by `cursor.distance_reset_timer`.
|
||||||
last_hover = false, -- Stores `mouse.hover` boolean of the last mouse event for enter/leave detection.
|
last_hover = false, -- Stores `mouse.hover` boolean of the last mouse event for enter/leave detection.
|
||||||
-- Event handlers that are only fired on zones defined during render loop.
|
-- Event handlers that are only fired on zones defined during render loop.
|
||||||
@@ -404,11 +406,27 @@ function cursor:direction_to_rectangle_distance(rect)
|
|||||||
return get_ray_to_rectangle_distance(self.x, self.y, end_x, end_y, rect)
|
return get_ray_to_rectangle_distance(self.x, self.y, end_x, end_y, rect)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@param id string
|
||||||
|
function cursor:register_disabler(id)
|
||||||
|
self.disablers[#self.disablers + 1] = id
|
||||||
|
if #self.disablers > 0 then
|
||||||
|
cursor.disabled = true
|
||||||
|
self:leave()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param id string
|
||||||
|
function cursor:unregister_disabler(id)
|
||||||
|
self.disablers = itable_filter(self.disablers, function(item) return item ~= id end)
|
||||||
|
if #self.disablers == 0 then cursor.disabled = false end
|
||||||
|
end
|
||||||
|
|
||||||
---@param event string
|
---@param event string
|
||||||
---@param shortcut Shortcut
|
---@param shortcut Shortcut
|
||||||
---@param cb? fun(shortcut: Shortcut)
|
---@param cb? fun(shortcut: Shortcut)
|
||||||
function cursor:create_handler(event, shortcut, cb)
|
function cursor:create_handler(event, shortcut, cb)
|
||||||
return function()
|
return function()
|
||||||
|
if self.disabled then return end
|
||||||
if cb then cb(shortcut) end
|
if cb then cb(shortcut) end
|
||||||
self:trigger(event, shortcut)
|
self:trigger(event, shortcut)
|
||||||
end
|
end
|
||||||
@@ -416,7 +434,7 @@ end
|
|||||||
|
|
||||||
-- Movement
|
-- Movement
|
||||||
local function handle_mouse_pos(_, mouse)
|
local function handle_mouse_pos(_, mouse)
|
||||||
if not mouse then return end
|
if not mouse or cursor.disabled then return end
|
||||||
if cursor.last_hover and not mouse.hover then
|
if cursor.last_hover and not mouse.hover then
|
||||||
cursor:leave()
|
cursor:leave()
|
||||||
elseif not (cursor.last_hover == false and mouse.hover == false) then -- filters out duplicate mouse out events
|
elseif not (cursor.last_hover == false and mouse.hover == false) then -- filters out duplicate mouse out events
|
||||||
@@ -426,7 +444,7 @@ local function handle_mouse_pos(_, mouse)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function handle_touch_pos(_, touches)
|
local function handle_touch_pos(_, touches)
|
||||||
if not touches then return end
|
if not touches or cursor.disabled then return end
|
||||||
local touch = touches[1]
|
local touch = touches[1]
|
||||||
if touch then
|
if touch then
|
||||||
cursor:move(touch.x, touch.y)
|
cursor:move(touch.x, touch.y)
|
||||||
@@ -466,4 +484,14 @@ mp.set_key_bindings({
|
|||||||
{'wheel_down', cursor:create_handler('wheel_down', create_shortcut('wheel_down'))},
|
{'wheel_down', cursor:create_handler('wheel_down', create_shortcut('wheel_down'))},
|
||||||
}, 'wheel', 'force')
|
}, 'wheel', 'force')
|
||||||
|
|
||||||
|
-- Monitor mpv UI's and disable uosc's cursor handling when any are open
|
||||||
|
mp.observe_property('user-data/mpv/context-menu/open', 'bool', function(_, value)
|
||||||
|
if value == true then cursor:register_disabler('context-menu')
|
||||||
|
else cursor:unregister_disabler('context-menu') end
|
||||||
|
end)
|
||||||
|
mp.observe_property('user-data/mpv/console/open', 'bool', function(_, value)
|
||||||
|
if value == true then cursor:register_disabler('console')
|
||||||
|
else cursor:unregister_disabler('console') end
|
||||||
|
end)
|
||||||
|
|
||||||
return cursor
|
return cursor
|
||||||
@@ -54,15 +54,14 @@ do
|
|||||||
|
|
||||||
-- alphanum sorting for humans in Lua
|
-- alphanum sorting for humans in Lua
|
||||||
-- http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua
|
-- http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua
|
||||||
local function padnum(n, d)
|
local function padnum(n)
|
||||||
return #d > 0 and ('%03d%s%.12f'):format(#n, n, tonumber(d) / (10 ^ #d))
|
return ("%03d%s"):format(#n, n)
|
||||||
or ('%03d%s'):format(#n, n)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function sort_lua(strings)
|
local function sort_lua(strings)
|
||||||
local tuples = {}
|
local tuples = {}
|
||||||
for i, f in ipairs(strings) do
|
for i, f in ipairs(strings) do
|
||||||
tuples[i] = {f:lower():gsub('0*(%d+)%.?(%d*)', padnum), f}
|
tuples[i] = {f:lower():gsub('0*(%d+)', padnum), f}
|
||||||
end
|
end
|
||||||
table.sort(tuples, function(a, b)
|
table.sort(tuples, function(a, b)
|
||||||
return a[1] == b[1] and #b[2] < #a[2] or a[1] < b[1]
|
return a[1] == b[1] and #b[2] < #a[2] or a[1] < b[1]
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Release1.2.0及Release1.2.0之前的发行版,都由于弹弹play接口使用政策改版,部分功能无法使用。如果发现插件功能异常,比如搜索弹幕总是显示无结果,请拉取或下载主分支最新源代码;或下载[最新发行版](https://github.com/Tony15246/uosc_danmaku/releases/latest)
|
> Release1.2.0及Release1.2.0之前的发行版,都由于弹弹play接口使用政策改版,部分功能无法使用。如果发现插件功能异常,比如搜索弹幕总是显示无结果,请拉取或下载主分支最新源代码;或下载[最新发行版](https://github.com/Tony15246/uosc_danmaku/releases/latest)
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 插件默认通过项目维护的 API 代理 `https://danmaku-api.152468.xyz` 访问弹弹play开放弹幕网络。普通用户无需注册账号,也无需配置或持有弹弹play的 AppId/AppSecret。代理包含缓存和访问频率限制,请勿用于批量抓取。
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> 已添加对mpv内部 `mp.input`的支持,在uosc不可用时通过键绑定调用此方式渲染菜单
|
> 已添加对mpv内部 `mp.input`的支持,在uosc不可用时通过键绑定调用此方式渲染菜单
|
||||||
>
|
>
|
||||||
@@ -63,12 +66,44 @@
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [项目简介](#项目简介)
|
||||||
|
- [主要功能](#主要功能)
|
||||||
|
- [安装](#安装)
|
||||||
|
- [下载](#下载)
|
||||||
|
- [基本配置](#基本配置)
|
||||||
|
- [uosc控件配置](#uosc控件配置)
|
||||||
|
- [绑定快捷键(可选)](#绑定快捷键可选)
|
||||||
|
- [拓展功能(可选)](#拓展功能可选)
|
||||||
|
- [从弹幕源向当前弹幕添加新弹幕内容(从网络url或本地添加弹幕)](#从弹幕源向当前弹幕添加新弹幕内容可选)
|
||||||
|
- [弹幕源延迟设置](#弹幕源延迟设置可选)
|
||||||
|
- [实时修改弹幕样式](#实时修改弹幕样式可选)
|
||||||
|
- [弹幕设置总菜单](#弹幕设置总菜单可选)
|
||||||
|
- [设置弹幕延迟(可选)](#设置弹幕延迟可选)
|
||||||
|
- [保存当前视频弹幕(可选)](#保存当前视频弹幕可选)
|
||||||
|
- [清空当前视频关联的弹幕源(可选)](#清空当前视频关联的弹幕源可选)
|
||||||
|
- [检查脚本更新(可选)](#检查脚本更新可选)
|
||||||
|
- [可配置选项(可选)](#可配置选项可选)
|
||||||
|
- [弹幕加载相关](#弹幕加载相关)
|
||||||
|
- [弹幕显示相关](#弹幕显示相关)
|
||||||
|
- [弹幕解析服务相关](#弹幕解析服务相关)
|
||||||
|
- [插件配置相关](#插件配置相关)
|
||||||
|
- [自定义弹幕样式相关配置](#自定义弹幕样式相关配置)
|
||||||
|
- [插件自定义属性](#插件自定义属性)
|
||||||
|
- [常见问题](#常见问题)
|
||||||
|
- [特别感谢](#特别感谢)
|
||||||
|
- [相关项目](#相关项目)
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
### 下载
|
### 下载
|
||||||
|
|
||||||
一般的mpv配置目录结构大致如下
|
一般的mpv配置目录结构大致如下
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Windows 系统上等价全局配置路径`%APPDATA%/mpv/`,也可使用 mpv.exe 所在目录的 portable_config 文件夹(便携配置路径)
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.config/mpv
|
~/.config/mpv
|
||||||
├── fonts
|
├── fonts
|
||||||
@@ -301,7 +336,7 @@ key script-message danmaku-delay <seconds> ${=time-pos}
|
|||||||
|
|
||||||
> #### 保存当前视频弹幕(可选)
|
> #### 保存当前视频弹幕(可选)
|
||||||
|
|
||||||
在视频播放时手动保存弹幕至视频所在文件夹,保存格式为 `xml`(注:此功能将保存为视频同名弹幕,若视频文件夹下存在同名文件将不会执行该功能)
|
在视频播放时手动保存弹幕,保存格式为 `xml`(注:此功能将保存为视频同名弹幕,若目标文件夹下存在同名文件将不会执行该功能)。默认保存至视频所在文件夹,也可以通过 `save_danmaku_path` 和 `save_danmaku_path_mode` 保存到指定文件夹。
|
||||||
|
|
||||||
想要通过快捷键使用此功能,请添加类似下面的配置到 `input.conf`中。从源添加弹幕功能对应的脚本消息为 `immediately_save_danmaku`。
|
想要通过快捷键使用此功能,请添加类似下面的配置到 `input.conf`中。从源添加弹幕功能对应的脚本消息为 `immediately_save_danmaku`。
|
||||||
|
|
||||||
@@ -472,7 +507,7 @@ save_danmaku
|
|||||||
|
|
||||||
#### 功能说明
|
#### 功能说明
|
||||||
|
|
||||||
当文件关闭时自动保存弹幕文件(xml格式)至视频同目录,保存的弹幕文件名与对应的视频文件名相同。配合[autoload_local_danmaku选项](#autoload_local_danmaku)可以实现弹幕自动保存到本地并且下次播放时自动加载本地保存的弹幕。此功能默认禁用。
|
当文件关闭时自动保存弹幕文件(xml格式),保存的弹幕文件名与对应的视频文件名相同。默认保存至视频同目录,配合[autoload_local_danmaku选项](#autoload_local_danmaku)可以实现弹幕自动保存到本地并且下次播放时自动加载本地保存的弹幕。此功能默认禁用。
|
||||||
|
|
||||||
> **⚠️NOTE!**
|
> **⚠️NOTE!**
|
||||||
>
|
>
|
||||||
@@ -492,35 +527,30 @@ save_danmaku
|
|||||||
save_danmaku=yes
|
save_danmaku=yes
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
如需保存到指定文件夹,请提前创建目标文件夹,然后配置 `save_danmaku_path` 和 `save_danmaku_path_mode`。插件不会自动创建目录。
|
||||||
|
|
||||||
---
|
保存本地媒体到指定文件夹时,文件名会包含视频父目录名,用于减少不同目录下同名视频的弹幕文件冲突。例如 `动画/01.mkv` 会保存为类似 `动画_01.xml`。网络媒体保存到指定文件夹时仍使用媒体标题作为文件名。
|
||||||
|
|
||||||
<details>
|
`save_danmaku_path_mode` 可选值如下:
|
||||||
<summary>
|
|
||||||
|
|
||||||
~~add_from_source~~
|
- `local`:默认值,仅本地媒体保存到 `save_danmaku_path`,网络媒体仍不保存
|
||||||
|
- `url`:仅网络媒体保存到 `save_danmaku_path`,本地媒体仍保存到视频同目录
|
||||||
|
- `all`:本地媒体和网络媒体都保存到 `save_danmaku_path`
|
||||||
|
|
||||||
> ~~开关记录通过 从弹幕源向当前弹幕添加新弹幕内容 关联过的弹幕源并自动加载(已废除)~~
|
例如,仅将网络媒体的弹幕保存到 `~~/danmaku`:
|
||||||
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
### add_from_source
|
|
||||||
|
|
||||||
> **⚠️NOTE!**
|
|
||||||
>
|
|
||||||
> 该可选配置项在Release v1.2.0之后已废除。现在通过 `从弹幕源向当前弹幕添加新弹幕内容`功能关联过的弹幕源被记录,并且下次播放同一个视频的时候自动关联并加载所有添加过的弹幕源,这样的行为已经成为了插件的默认行为,不需要再通过 `add_from_source`来开启。在[从源获取弹幕](#从弹幕源向当前弹幕添加新弹幕内容可选)菜单中可以可视化地管理所有添加过的弹幕源。
|
|
||||||
|
|
||||||
#### 功能说明
|
|
||||||
|
|
||||||
开启此选项后,通过 `从弹幕源向当前弹幕添加新弹幕内容`功能关联过的弹幕源会被记录,并且下次播放同一个视频的时候会自动关联并加载添加过的弹幕源。
|
|
||||||
|
|
||||||
#### 使用方法
|
|
||||||
|
|
||||||
想要开启此选项,请在mpv配置文件夹下的 `script-opts`中创建 `uosc_danmaku.conf`文件并添加如下内容:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
add_from_source=yes
|
save_danmaku=yes
|
||||||
|
save_danmaku_path=~~/danmaku
|
||||||
|
save_danmaku_path_mode=url
|
||||||
|
```
|
||||||
|
|
||||||
|
例如,将所有媒体的弹幕都保存到 `~~/danmaku`:
|
||||||
|
|
||||||
|
```
|
||||||
|
save_danmaku=yes
|
||||||
|
save_danmaku_path=~~/danmaku
|
||||||
|
save_danmaku_path_mode=all
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -613,6 +643,35 @@ merge_tolerance=1
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
merge_without_style
|
||||||
|
|
||||||
|
> 决定在合并重复弹幕时是否忽略类型和颜色差异
|
||||||
|
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
### merge_without_style
|
||||||
|
|
||||||
|
#### 功能说明
|
||||||
|
|
||||||
|
配合 `merge_tolerance` 使用。默认值: `no`(关闭)
|
||||||
|
|
||||||
|
当开启此选项时,即使属于不同位置类型(如顶部、底部、滚动)或不同颜色(无论色差多大),只要弹幕文本内容相同并且在 `merge_tolerance` 指定的时间容差范围内,就会被强制合并成一条弹幕。
|
||||||
|
关闭时,仅当弹幕内容相同、位置类型相同、且颜色肉眼不可区分(色差极小)时,才会进行弹幕合并。
|
||||||
|
|
||||||
|
#### 使用方法
|
||||||
|
|
||||||
|
想要使用此选项,请在mpv配置文件夹下的 `script-opts`中创建 `uosc_danmaku.conf`文件并自定义如下内容:
|
||||||
|
|
||||||
|
```
|
||||||
|
merge_without_style=yes
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
max_screen_danmaku
|
max_screen_danmaku
|
||||||
@@ -713,18 +772,24 @@ api_server
|
|||||||
|
|
||||||
#### 功能说明
|
#### 功能说明
|
||||||
|
|
||||||
允许自定义弹幕 API 的服务地址
|
允许自定义弹幕 API 的服务地址。默认使用项目维护的 `https://danmaku-api.152468.xyz`,由代理完成弹弹play API 鉴权,插件用户无需配置密钥。
|
||||||
|
|
||||||
|
可指定多个用逗号分隔的有序 api_server 列表。
|
||||||
|
|
||||||
|
支持每项使用 '|' 或 '#' 分隔备注,例如: "https://a.example.com|备用A" 或 "https://b.example.com#备用B"
|
||||||
|
|
||||||
> **⚠️NOTE!**
|
> **⚠️NOTE!**
|
||||||
>
|
>
|
||||||
> 请确保自定义服务的 API 与弹弹play 的兼容,已知兼容:[misaka_danmu_server](https://github.com/l429609201/misaka_danmu_server),[danmu_api](https://github.com/huangxd-/danmu_api)
|
> 请确保自定义服务的 API 与弹弹play 的兼容,已知兼容:[misaka_danmu_server](https://github.com/l429609201/misaka_danmu_server),[danmu_api](https://github.com/huangxd-/danmu_api)
|
||||||
|
>
|
||||||
|
> 通过默认 API 代理访问弹弹play时,无需配置弹弹play的 AppId/AppSecret;如需使用个人申请的弹弹play AppId/AppSecret 凭据,可以自行部署服务端代理,并将 `api_server` 指向该代理。
|
||||||
|
|
||||||
#### 使用方法
|
#### 使用方法
|
||||||
|
|
||||||
想要使用此选项,请在mpv配置文件夹下的 `script-opts`中创建 `uosc_danmaku.conf`文件并自定义如下内容:
|
想要使用此选项,请在mpv配置文件夹下的 `script-opts`中创建 `uosc_danmaku.conf`文件并自定义如下内容:
|
||||||
|
|
||||||
```
|
```
|
||||||
api_server=https://api.dandanplay.net
|
api_server=https://danmaku-api.152468.xyz
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -813,7 +878,7 @@ user_agent
|
|||||||
|
|
||||||
> **⚠️NOTE!**
|
> **⚠️NOTE!**
|
||||||
>
|
>
|
||||||
> User-Agent格式必须符合弹弹play的标准,否则无法成功请求。具体格式要求见[弹弹play官方文档](https://github.com/kaedei/dandanplay-libraryindex/blob/master/api/OpenPlatform.md#5user-agent)
|
> 使用默认 API 代理时无需为弹弹play鉴权而修改 User-Agent。直接接入弹弹play官方 API 或其他自定义服务时,请遵循对应服务的 User-Agent 要求。
|
||||||
>
|
>
|
||||||
> 若想提高URL播放的哈希匹配成功率,可以将此项设为 `mpv`或浏览器的User-Agent
|
> 若想提高URL播放的哈希匹配成功率,可以将此项设为 `mpv`或浏览器的User-Agent
|
||||||
|
|
||||||
@@ -1015,6 +1080,9 @@ blacklist_path=
|
|||||||
- `user-data/uosc_danmaku/has-danmaku`
|
- `user-data/uosc_danmaku/has-danmaku`
|
||||||
从`user-data/uosc_danmaku/has-danmaku`属性中可以获取到表示当前是否有弹幕在显示的布尔值,具体用法可以参考[此pr](https://github.com/Tony15246/uosc_danmaku/pull/276)
|
从`user-data/uosc_danmaku/has-danmaku`属性中可以获取到表示当前是否有弹幕在显示的布尔值,具体用法可以参考[此pr](https://github.com/Tony15246/uosc_danmaku/pull/276)
|
||||||
|
|
||||||
|
- `user-data/uosc_danmaku/danmaku-switch-on`
|
||||||
|
从`user-data/uosc_danmaku/danmaku-switch-on`属性中可以获取到表示当前弹幕开关状态的布尔值,具体用法可以参考[此issue](https://github.com/Tony15246/uosc_danmaku/issues/362)
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
### 来自弹弹play的弹幕源问题如何从根源进行调整解决
|
### 来自弹弹play的弹幕源问题如何从根源进行调整解决
|
||||||
@@ -1049,10 +1117,12 @@ blacklist_path=
|
|||||||
- 弹幕格式解析转换:[DanmakuConvert](https://github.com/timerring/DanmakuConvert)
|
- 弹幕格式解析转换:[DanmakuConvert](https://github.com/timerring/DanmakuConvert)
|
||||||
- 简繁转换:[OpenCC](https://github.com/BYVoid/OpenCC)
|
- 简繁转换:[OpenCC](https://github.com/BYVoid/OpenCC)
|
||||||
- lua原生md5计算实现:https://github.com/rkscv/danmaku
|
- lua原生md5计算实现:https://github.com/rkscv/danmaku
|
||||||
|
- lua原生zip解压缩实现:[lua-inflate](https://github.com/TohruMKDM/lua-inflate)
|
||||||
|
- 爱优腾及芒果TV的弹幕解析参考:https://github.com/lyz05/danmaku
|
||||||
- b站在线播放弹幕获取实现参考:[MPV-Play-BiliBili-Comments](https://github.com/itKelis/MPV-Play-BiliBili-Comments)
|
- b站在线播放弹幕获取实现参考:[MPV-Play-BiliBili-Comments](https://github.com/itKelis/MPV-Play-BiliBili-Comments)
|
||||||
- 巴哈姆特在线播放弹幕获取实现参考:[MPV-Play-BAHA-Comments](https://github.com/s594569321/MPV-Play-BAHA-Comments)
|
- 巴哈姆特在线播放弹幕获取实现参考:[MPV-Play-BAHA-Comments](https://github.com/s594569321/MPV-Play-BAHA-Comments)
|
||||||
|
|
||||||
## 相关项目
|
## 相关项目
|
||||||
|
|
||||||
- [slqy123/uosc_danmaku](https://github.com/slqy123/uosc_danmaku) 本项目的fork版本,实现了通过dandanplay api发送弹幕的功能,由于版本的兼容性以及功能的易用性问题未被合并,具体讨论请参阅 [#220](https://github.com/Tony15246/uosc_danmaku/pull/220)
|
- [slqy123/uosc_danmaku](https://github.com/slqy123/uosc_danmaku) 本项目的fork版本,实现了通过dandanplay api发送弹幕的功能,由于版本的兼容性以及功能的易用性问题未被合并,具体讨论请参阅 [#220](https://github.com/Tony15246/uosc_danmaku/pull/220)
|
||||||
- [Loukyuu1120/uosc_danmaku](https://github.com/Loukyuu1120/uosc_danmaku) 本项目的fork版本,实现了自定义多个 api_servers 与 弹幕来源选择菜单 功能,具体讨论请参阅 [#282](https://github.com/Tony15246/uosc_danmaku/issues/282)
|
- ~~[Loukyuu1120/uosc_danmaku](https://github.com/Loukyuu1120/uosc_danmaku) 本项目的fork版本,实现了自定义多个 api_servers 与 弹幕来源选择菜单 功能,具体讨论请参阅 [#282](https://github.com/Tony15246/uosc_danmaku/issues/282)~~ 相关功能主仓库已实现
|
||||||
@@ -21,47 +21,114 @@ end
|
|||||||
|
|
||||||
-- 写入history.json
|
-- 写入history.json
|
||||||
-- 读取episodeId获取danmaku
|
-- 读取episodeId获取danmaku
|
||||||
function set_episode_id(input, from_menu)
|
function set_episode_id(input, from_menu, api_server)
|
||||||
from_menu = from_menu or false
|
from_menu = from_menu or false
|
||||||
DANMAKU.source = "dandanplay"
|
DANMAKU.source = "dandanplay"
|
||||||
local api_server = options.api_server
|
local selected_server = api_server
|
||||||
for url, source in pairs(DANMAKU.sources) do
|
for url, source in pairs(DANMAKU.sources) do
|
||||||
if source.from == "api_server" then
|
if source.from == "api_server" then
|
||||||
if not source.from_history then
|
if not source.from_history then
|
||||||
DANMAKU.sources[url] = nil
|
DANMAKU.sources[url] = nil
|
||||||
else
|
else
|
||||||
DANMAKU.sources[url]["data"] = nil
|
DANMAKU.sources[url]["data"] = nil
|
||||||
api_server = source.api_server or options.api_server
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local episodeId = tonumber(input)
|
|
||||||
|
|
||||||
local main_url = api_server .. "/api/v2/comment/" .. episodeId .. "?withRelated=true&chConvert=0"
|
if not api_server then
|
||||||
add_source_to_history(main_url, { from = "api_server", api_server = api_server })
|
if DANMAKU.api_server ~= nil then
|
||||||
write_history(episodeId, api_server)
|
selected_server = DANMAKU.api_server
|
||||||
|
else
|
||||||
|
local servers = get_api_server_list(options.api_server)
|
||||||
|
if servers and #servers > 0 then
|
||||||
|
selected_server = servers[1]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
DANMAKU.api_server = selected_server
|
||||||
|
|
||||||
|
local episodeId = tonumber(input)
|
||||||
|
write_history(episodeId, selected_server)
|
||||||
set_danmaku_button()
|
set_danmaku_button()
|
||||||
fetch_danmaku(episodeId, from_menu, api_server)
|
fetch_danmaku(episodeId, from_menu, selected_server)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 回退使用额外的弹幕获取方式
|
-- 回退使用额外的弹幕获取方式
|
||||||
function get_danmaku_fallback(query)
|
function get_danmaku_fallback(query)
|
||||||
local url = options.fallback_server .. "/?ac=dm&url=" .. query
|
local function do_fallback()
|
||||||
msg.verbose("尝试获取弹幕:" .. url)
|
if options.fallback_server == "" then return end
|
||||||
|
local url = options.fallback_server .. "/?ac=dm&url=" .. query
|
||||||
|
msg.verbose("尝试获取弹幕:" .. url)
|
||||||
|
|
||||||
local args = make_danmaku_request_args("GET", url)
|
local args = make_danmaku_request_args("GET", url)
|
||||||
if not args then return end
|
if not args then return end
|
||||||
|
|
||||||
fetch_danmaku_data(args, function(data)
|
fetch_danmaku_data(args, function(data)
|
||||||
if not data or not data["comments"] or data["count"] <= 1 then
|
if data ~= nil and data["xml"] ~= nil then
|
||||||
msg.info("备用服务器无数据或返回格式不正确")
|
if DANMAKU.sources[query] ~= nil then
|
||||||
show_message("备用服务器无数据或返回格式不正确", 3)
|
DANMAKU.sources[query]["data"] = data["xml"]
|
||||||
return
|
else
|
||||||
end
|
DANMAKU.sources[query] = {from = "user_custom", data = data["xml"]}
|
||||||
|
end
|
||||||
|
load_danmaku(true)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
save_danmaku_data(data["comments"], query, "user_custom")
|
if not data or not data["comments"] or data["count"] <= 1 then
|
||||||
load_danmaku(true)
|
msg.info("备用服务器无数据或返回格式不正确")
|
||||||
end)
|
show_message("备用服务器无数据或返回格式不正确", 3)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
save_danmaku_data(data["comments"], query, "user_custom")
|
||||||
|
load_danmaku(true)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
if query:find('bilibili.com') or query:find('bilivideo.c[nom]+') then
|
||||||
|
load_danmaku_for_bilibili(query, function(success)
|
||||||
|
if not success then do_fallback() end
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if query:find('bahamut.akamaized.net') then
|
||||||
|
load_danmaku_for_bahamut(query, function(success)
|
||||||
|
if not success then do_fallback() end
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if query:find('mgtv.com') then
|
||||||
|
load_danmaku_for_mgtv(query, function(success)
|
||||||
|
if not success then do_fallback() end
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if query:find('iqiyi.com') then
|
||||||
|
load_danmaku_for_iqiyi(query, function(success)
|
||||||
|
if not success then do_fallback() end
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if query:find('v.qq.com') then
|
||||||
|
load_danmaku_for_tencent(query, function(success)
|
||||||
|
if not success then do_fallback() end
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if query:find('v.youku.com') then
|
||||||
|
load_danmaku_for_youku(query, function(success)
|
||||||
|
if not success then do_fallback() end
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
do_fallback()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 返回弹幕请求参数
|
-- 返回弹幕请求参数
|
||||||
@@ -91,6 +158,8 @@ function make_danmaku_request_args(method, url, headers, body)
|
|||||||
table.insert(args, 'Content-Type: application/json')
|
table.insert(args, 'Content-Type: application/json')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
table.insert(args, '--compressed')
|
||||||
|
|
||||||
if url:find("api%.dandanplay%.") then
|
if url:find("api%.dandanplay%.") then
|
||||||
local time = os.time()
|
local time = os.time()
|
||||||
local appid = "UgjRIH45lE1BBLNmir1WKw=="
|
local appid = "UgjRIH45lE1BBLNmir1WKw=="
|
||||||
@@ -153,8 +222,8 @@ local function normalize_danmaku_response(d)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- 尝试通过解析文件名匹配剧集
|
-- 尝试通过解析文件名匹配剧集
|
||||||
local function match_episode(animeTitle, bangumiId, episode_num)
|
local function match_episode(animeTitle, bangumiId, episode_num, api_server)
|
||||||
local url = options.api_server .. "/api/v2/bangumi/" .. bangumiId
|
local url = api_server .. "/api/v2/bangumi/" .. bangumiId
|
||||||
local args = make_danmaku_request_args("GET", url)
|
local args = make_danmaku_request_args("GET", url)
|
||||||
|
|
||||||
if args == nil then
|
if args == nil then
|
||||||
@@ -162,7 +231,6 @@ local function match_episode(animeTitle, bangumiId, episode_num)
|
|||||||
end
|
end
|
||||||
|
|
||||||
call_cmd_async(args, function(error, json)
|
call_cmd_async(args, function(error, json)
|
||||||
async_running = false
|
|
||||||
if error then
|
if error then
|
||||||
show_message("HTTP 请求失败,打开控制台查看详情", 5)
|
show_message("HTTP 请求失败,打开控制台查看详情", 5)
|
||||||
msg.error(error)
|
msg.error(error)
|
||||||
@@ -180,7 +248,7 @@ local function match_episode(animeTitle, bangumiId, episode_num)
|
|||||||
if ep_num and ep_num == tonumber(episode_num) then
|
if ep_num and ep_num == tonumber(episode_num) then
|
||||||
DANMAKU.anime = animeTitle
|
DANMAKU.anime = animeTitle
|
||||||
DANMAKU.episode = episode.episodeTitle
|
DANMAKU.episode = episode.episodeTitle
|
||||||
set_episode_id(episode.episodeId)
|
set_episode_id(episode.episodeId, nil, api_server)
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -188,9 +256,7 @@ local function match_episode(animeTitle, bangumiId, episode_num)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function match_anime()
|
local function match_anime()
|
||||||
local animes = {}
|
|
||||||
local anime_type = "tvseries"
|
local anime_type = "tvseries"
|
||||||
local type_count = 0
|
|
||||||
local title, season_num, episode_num = parse_title()
|
local title, season_num, episode_num = parse_title()
|
||||||
if not episode_num then
|
if not episode_num then
|
||||||
msg.info("无法解析剧集信息")
|
msg.info("无法解析剧集信息")
|
||||||
@@ -201,44 +267,49 @@ local function match_anime()
|
|||||||
anime_type = "ova"
|
anime_type = "ova"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- 并发在多个 api_server 上搜索,遇到第一个可接受的匹配就取消其余请求
|
||||||
local encoded_query = url_encode(title)
|
local encoded_query = url_encode(title)
|
||||||
local url = options.api_server .. "/api/v2/search/anime"
|
local servers = get_api_server_list(options.api_server)
|
||||||
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
|
local matched = false
|
||||||
|
local cancel_fn = nil
|
||||||
|
|
||||||
call_cmd_async(args, function(error, json)
|
local function build_args(server)
|
||||||
async_running = false
|
local url = server .. "/api/v2/search/anime"
|
||||||
if error then
|
local full_url = url .. "?keyword=" .. encoded_query
|
||||||
show_message("HTTP 请求失败,打开控制台查看详情", 5)
|
return make_danmaku_request_args("GET", full_url)
|
||||||
msg.error(error)
|
end
|
||||||
|
|
||||||
|
local function per_response(server, err, out)
|
||||||
|
if matched then return end
|
||||||
|
if err then
|
||||||
|
msg.debug(("search anime failed for %s: %s"):format(server, tostring(err)))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
local data = utils.parse_json(out)
|
||||||
local data = utils.parse_json(json)
|
|
||||||
if not data or not data.animes then
|
if not data or not data.animes then
|
||||||
msg.info("无结果")
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
local local_candidates = {}
|
||||||
for _, anime in ipairs(data.animes) do
|
for _, anime in ipairs(data.animes) do
|
||||||
if anime.type == anime_type then
|
if anime.type == anime_type then
|
||||||
type_count = type_count + 1
|
table.insert(local_candidates, anime)
|
||||||
table.insert(animes, anime)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
if #local_candidates == 1 then
|
||||||
if type_count == 1 then
|
matched = true
|
||||||
match_episode(animes[1].animeTitle, animes[1].bangumiId, episode_num)
|
local a = local_candidates[1]
|
||||||
elseif type_count > 1 and season_num then
|
match_episode(a.animeTitle, a.bangumiId, episode_num, server)
|
||||||
|
if cancel_fn then pcall(cancel_fn) end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if #local_candidates > 1 and season_num then
|
||||||
local best_match, best_score = nil, -1
|
local best_match, best_score = nil, -1
|
||||||
local target_title = title
|
local target_title = title
|
||||||
if tonumber(season_num) > 1 then
|
if tonumber(season_num) > 1 then
|
||||||
target_title = title .. " 第" .. number_to_chinese(season_num) .. "季"
|
target_title = title .. " 第" .. number_to_chinese(season_num) .. "季"
|
||||||
end
|
end
|
||||||
for _, anime in ipairs(animes) do
|
for _, anime in ipairs(local_candidates) do
|
||||||
local animeTitle = tostring(anime.animeTitle or "")
|
local animeTitle = tostring(anime.animeTitle or "")
|
||||||
animeTitle = animeTitle:gsub("^%s*(.-)%s*$", "%1")
|
animeTitle = animeTitle:gsub("^%s*(.-)%s*$", "%1")
|
||||||
:gsub("%s*%(.-%)%s*$", "")
|
:gsub("%s*%(.-%)%s*$", "")
|
||||||
@@ -253,17 +324,24 @@ local function match_anime()
|
|||||||
best_match = anime
|
best_match = anime
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if best_match and best_score >= 0.75 then
|
if best_match and best_score >= 0.75 then
|
||||||
|
matched = true
|
||||||
msg.info(("模糊匹配选中: %s (score=%.2f)"):format(best_match.animeTitle, best_score))
|
msg.info(("模糊匹配选中: %s (score=%.2f)"):format(best_match.animeTitle, best_score))
|
||||||
match_episode(best_match.animeTitle, best_match.bangumiId, episode_num)
|
match_episode(best_match.animeTitle, best_match.bangumiId, episode_num, server)
|
||||||
else
|
if cancel_fn then pcall(cancel_fn) end
|
||||||
msg.info("匹配到多个结果,但相似度不足,请手动搜索")
|
return
|
||||||
end
|
end
|
||||||
else
|
end
|
||||||
|
-- 未找到可接受匹配,继续等待其他服务器的返回
|
||||||
|
end
|
||||||
|
|
||||||
|
local function final_cb()
|
||||||
|
if not matched then
|
||||||
msg.info("没有找到合适的匹配结果")
|
msg.info("没有找到合适的匹配结果")
|
||||||
end
|
end
|
||||||
end)
|
end
|
||||||
|
|
||||||
|
cancel_fn = parallel_requests(servers, build_args, per_response, final_cb, { concurrency = 5, per_request_timeout = 15 })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 执行哈希匹配获取弹幕
|
-- 执行哈希匹配获取弹幕
|
||||||
@@ -271,7 +349,7 @@ local function match_file(file_path, file_name, callback)
|
|||||||
-- 计算文件哈希
|
-- 计算文件哈希
|
||||||
local hash = nil
|
local hash = nil
|
||||||
local file_info = utils.file_info(file_path)
|
local file_info = utils.file_info(file_path)
|
||||||
if file_info and file_info.size > 16 * 1024 * 1024 then
|
if file_info and file_info.size >= 16 * 1024 * 1024 then
|
||||||
local file, error = io.open(normalize(file_path), 'rb')
|
local file, error = io.open(normalize(file_path), 'rb')
|
||||||
if file and not error then
|
if file and not error then
|
||||||
local m = MD5.new()
|
local m = MD5.new()
|
||||||
@@ -300,50 +378,66 @@ local function match_file(file_path, file_name, callback)
|
|||||||
file_name = title
|
file_name = title
|
||||||
end
|
end
|
||||||
|
|
||||||
local url = options.api_server .. "/api/v2/match"
|
local servers = get_api_server_list(options.api_server)
|
||||||
local args = make_danmaku_request_args("POST", url, {
|
|
||||||
["Content-Type"] = "application/json"
|
local matched = false
|
||||||
}, {
|
local cancel_fn = nil
|
||||||
|
|
||||||
|
local function build_args(server)
|
||||||
|
local url = server .. "/api/v2/match"
|
||||||
|
return make_danmaku_request_args("POST", url, { ["Content-Type"] = "application/json" }, {
|
||||||
fileName = file_name,
|
fileName = file_name,
|
||||||
fileHash = hash or "a1b2c3d4e5f67890abcd1234ef567890",
|
fileHash = hash or "a1b2c3d4e5f67890abcd1234ef567890",
|
||||||
matchMode = "hashAndFileName"
|
matchMode = "hashAndFileName"
|
||||||
}
|
})
|
||||||
)
|
end
|
||||||
|
|
||||||
if not args then return end
|
local function per_response(server, err, out)
|
||||||
|
if matched then return end
|
||||||
call_cmd_async(args, function(error, json)
|
if err then
|
||||||
async_running = false
|
msg.debug(("match failed for %s: %s"):format(server, tostring(err)))
|
||||||
if error then
|
|
||||||
show_message("HTTP 请求失败,打开控制台查看详情", 5)
|
|
||||||
callback(error)
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local data = utils.parse_json(json)
|
local data = utils.parse_json(out)
|
||||||
if not data or not data.isMatched then
|
if not data or not data.isMatched then
|
||||||
callback("没有匹配的剧集")
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
matched = true
|
||||||
DANMAKU.anime = data.matches[1].animeTitle
|
DANMAKU.anime = data.matches[1].animeTitle
|
||||||
DANMAKU.episode = data.matches[1].episodeTitle
|
DANMAKU.episode = data.matches[1].episodeTitle
|
||||||
|
|
||||||
-- 获取并加载弹幕数据
|
set_episode_id(data.matches[1].episodeId, nil, server)
|
||||||
set_episode_id(data.matches[1].episodeId)
|
if cancel_fn then pcall(cancel_fn) end
|
||||||
end)
|
if callback then pcall(callback) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function final_cb()
|
||||||
|
if not matched then
|
||||||
|
callback("没有匹配的剧集")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
cancel_fn = parallel_requests(servers, build_args, per_response, final_cb, { concurrency = 5, per_request_timeout = 15 })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 异步获取弹幕数据
|
-- 异步获取弹幕数据
|
||||||
function fetch_danmaku_data(args, callback)
|
function fetch_danmaku_data(args, callback)
|
||||||
call_cmd_async(args, function(error, json)
|
call_cmd_async(args, function(error, json)
|
||||||
async_running = false
|
|
||||||
if error then
|
if error then
|
||||||
show_message("获取数据失败", 3)
|
show_message("获取数据失败", 3)
|
||||||
msg.error("HTTP 请求失败:" .. error)
|
msg.error("HTTP 请求失败:" .. error)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local data = utils.parse_json(json)
|
local data = utils.parse_json(json)
|
||||||
data = normalize_danmaku_response(data)
|
if data ~= nil then
|
||||||
|
data = normalize_danmaku_response(data)
|
||||||
|
else
|
||||||
|
local danmaku = parse_xml_danmaku(json)
|
||||||
|
if #danmaku > 0 then
|
||||||
|
data = {}
|
||||||
|
data["xml"] = danmaku
|
||||||
|
end
|
||||||
|
end
|
||||||
callback(data)
|
callback(data)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -359,6 +453,26 @@ function save_danmaku_data(comments, query, danmaku_source)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function save_danmaku_xml(url, xml_string)
|
||||||
|
local danmaku_list = parse_xml_danmaku(xml_string)
|
||||||
|
|
||||||
|
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 save_danmaku_json(url, json_string)
|
||||||
|
local danmaku_list = parse_json_danmaku(json_string)
|
||||||
|
|
||||||
|
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 save_danmaku_downloaded(url, downloaded_file)
|
function save_danmaku_downloaded(url, downloaded_file)
|
||||||
local danmaku_list = parse_danmaku_file(downloaded_file)
|
local danmaku_list = parse_danmaku_file(downloaded_file)
|
||||||
if file_exists(downloaded_file) then
|
if file_exists(downloaded_file) then
|
||||||
@@ -371,39 +485,6 @@ function save_danmaku_downloaded(url, downloaded_file)
|
|||||||
end
|
end
|
||||||
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)
|
function handle_fetched_danmaku(data, url, from_menu)
|
||||||
if data and data["comments"] then
|
if data and data["comments"] then
|
||||||
@@ -426,7 +507,7 @@ end
|
|||||||
-- 匹配弹幕库 comment, 仅匹配dandan本身弹幕库
|
-- 匹配弹幕库 comment, 仅匹配dandan本身弹幕库
|
||||||
-- 通过danmaku api(url)+id获取弹幕
|
-- 通过danmaku api(url)+id获取弹幕
|
||||||
function fetch_danmaku(episodeId, from_menu, api_server)
|
function fetch_danmaku(episodeId, from_menu, api_server)
|
||||||
local url = (api_server or options.api_server) .. "/api/v2/comment/" .. episodeId .. "?withRelated=true&chConvert=0"
|
local url = api_server .. "/api/v2/comment/" .. episodeId .. "?withRelated=true&chConvert=0"
|
||||||
show_message("弹幕加载中...", 30)
|
show_message("弹幕加载中...", 30)
|
||||||
msg.verbose("尝试获取弹幕:" .. url)
|
msg.verbose("尝试获取弹幕:" .. url)
|
||||||
local args = make_danmaku_request_args("GET", url)
|
local args = make_danmaku_request_args("GET", url)
|
||||||
@@ -499,18 +580,60 @@ end
|
|||||||
--通过输入源url获取弹幕库
|
--通过输入源url获取弹幕库
|
||||||
function add_danmaku_source_online(query, from_menu)
|
function add_danmaku_source_online(query, from_menu)
|
||||||
set_danmaku_button()
|
set_danmaku_button()
|
||||||
local url = options.api_server .. "/api/v2/extcomment?url=" .. url_encode(query)
|
|
||||||
show_message("弹幕加载中...", 30)
|
show_message("弹幕加载中...", 30)
|
||||||
msg.verbose("尝试获取弹幕:" .. url)
|
msg.verbose("尝试获取弹幕:" .. query)
|
||||||
local args = make_danmaku_request_args("GET", url)
|
|
||||||
|
|
||||||
if args == nil then
|
local servers = get_api_server_list(options.api_server)
|
||||||
|
|
||||||
|
-- 过滤掉指向 dandanplay.net 的服务器
|
||||||
|
local filtered = {}
|
||||||
|
for _, s in ipairs(servers) do
|
||||||
|
if type(s) == "string" and not s:lower():find("dandanplay%.net") then
|
||||||
|
table.insert(filtered, s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
servers = filtered
|
||||||
|
if #servers == 0 then
|
||||||
|
get_danmaku_fallback(query)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
fetch_danmaku_data(args, function(data)
|
local matched = false
|
||||||
handle_danmaku_data(query, data, from_menu)
|
local cancel_fn = nil
|
||||||
end)
|
|
||||||
|
local function build_args(server)
|
||||||
|
local url = server .. "/api/v2/extcomment?url=" .. url_encode(query)
|
||||||
|
return make_danmaku_request_args("GET", url)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function per_response(server, err, out)
|
||||||
|
if matched then return end
|
||||||
|
if err then
|
||||||
|
msg.debug(("extcomment failed for %s: %s"):format(server, tostring(err)))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local data = utils.parse_json(out)
|
||||||
|
data = normalize_danmaku_response(data)
|
||||||
|
if not data or not data["comments"] or data["count"] <= 1 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
matched = true
|
||||||
|
-- 保存并加载弹幕
|
||||||
|
save_danmaku_data(data["comments"], query, "user_custom")
|
||||||
|
load_danmaku(from_menu)
|
||||||
|
-- 取消其他未完成请求
|
||||||
|
if cancel_fn then pcall(cancel_fn) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function final_cb()
|
||||||
|
if not matched then
|
||||||
|
-- 所有服务器都未返回有效弹幕,回退到备用服务器
|
||||||
|
msg.info("所有服务器均无有效弹幕,尝试备用服务器")
|
||||||
|
get_danmaku_fallback(query)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
cancel_fn = parallel_requests(servers, build_args, per_response, final_cb, { concurrency = 3, per_request_timeout = 30 })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 将弹幕转换为 Lua table
|
-- 将弹幕转换为 Lua table
|
||||||
@@ -578,8 +701,6 @@ function get_danmaku_with_hash(file_name, file_path)
|
|||||||
end
|
end
|
||||||
|
|
||||||
call_cmd_async(arg, function(error)
|
call_cmd_async(arg, function(error)
|
||||||
async_running = false
|
|
||||||
|
|
||||||
file_path = utils.join_path(DANMAKU_PATH, temp_file)
|
file_path = utils.join_path(DANMAKU_PATH, temp_file)
|
||||||
|
|
||||||
match_file(file_path, file_name, function(error)
|
match_file(file_path, file_name, function(error)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
local utils = require 'mp.utils'
|
local utils = require 'mp.utils'
|
||||||
local msg = require 'mp.msg'
|
local msg = require 'mp.msg'
|
||||||
|
|
||||||
|
local cached_series_playlinks = {}
|
||||||
|
|
||||||
local Source = {
|
local Source = {
|
||||||
["b 站"] = "bilibili1",
|
["b 站"] = "bilibili1",
|
||||||
|
["芒果TV"] = "imgo",
|
||||||
["腾讯"] = "qq",
|
["腾讯"] = "qq",
|
||||||
["爱奇艺"] = "qiyi",
|
["爱奇艺"] = "qiyi",
|
||||||
["优酷"] = "youku",
|
["优酷"] = "youku",
|
||||||
@@ -83,6 +86,31 @@ local function query_tmdb(title, class, menu)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- 从单个 seriesPlaylinks 项解析出有效的播放 URL
|
||||||
|
local function extract_episode_url(item, playlink)
|
||||||
|
if not item then return nil end
|
||||||
|
if type(item) == 'string' then
|
||||||
|
return playlink or item
|
||||||
|
elseif type(item) == 'table' then
|
||||||
|
return item.url or nil
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 将 seriesPlaylinks 转换为统一的 episode_rows 列表:{ index=string, url=string }
|
||||||
|
local function build_episode_rows(seriesPlaylinks, playlink)
|
||||||
|
if not seriesPlaylinks or type(seriesPlaylinks) ~= 'table' then return nil end
|
||||||
|
local rows = {}
|
||||||
|
for i, it in ipairs(seriesPlaylinks) do
|
||||||
|
local url = extract_episode_url(it, playlink)
|
||||||
|
if url and url ~= '' then
|
||||||
|
table.insert(rows, { index = tostring(i), url = url })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #rows == 0 then return nil end
|
||||||
|
return rows
|
||||||
|
end
|
||||||
|
|
||||||
local function get_number(cat, id, site)
|
local function get_number(cat, id, site)
|
||||||
local url = string.format("https://api.web.360kan.com/v1/detail?cat=%s&id=%s&site=%s",
|
local url = string.format("https://api.web.360kan.com/v1/detail?cat=%s&id=%s&site=%s",
|
||||||
cat, id, site)
|
cat, id, site)
|
||||||
@@ -107,6 +135,53 @@ local function get_number(cat, id, site)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- 使用 /v1/detail 分批获取集数(每批最多200集)
|
||||||
|
local function get_episodes_v1(cat, id, site, number)
|
||||||
|
if not number or tonumber(number) == 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local batch_size = 200
|
||||||
|
local start_idx = 1
|
||||||
|
local episodes = {}
|
||||||
|
while start_idx <= tonumber(number) do
|
||||||
|
local end_idx = math.min(start_idx + batch_size - 1, tonumber(number))
|
||||||
|
local url = string.format("https://api.web.360kan.com/v1/detail?cat=%s&id=%s&start=%s&end=%s&site=%s",
|
||||||
|
cat, id, start_idx, end_idx, site)
|
||||||
|
|
||||||
|
local cmd = { "curl", "-s", url }
|
||||||
|
local res = mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
args = cmd,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not res.status or res.status ~= 0 then
|
||||||
|
msg.error(string.format("Failed to fetch detail batch %d-%d: %s", start_idx, end_idx, res.stderr or "unknown"))
|
||||||
|
if start_idx == 1 then
|
||||||
|
return nil
|
||||||
|
else
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local result = utils.parse_json(res.stdout)
|
||||||
|
if result and result.data and result.data.allepidetail and result.data.allepidetail[site] then
|
||||||
|
for _, it in ipairs(result.data.allepidetail[site]) do
|
||||||
|
table.insert(episodes, { index = tostring(it.playlink_num), url = it.url })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
start_idx = end_idx + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
if #episodes == 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return episodes
|
||||||
|
end
|
||||||
|
|
||||||
local function get_episodes_v2(cat, id, site)
|
local function get_episodes_v2(cat, id, site)
|
||||||
local s_param = string.format('[{"cat_id":"%s","ent_id":"%s","site":"%s"}]', tostring(cat), tostring(id), tostring(site))
|
local s_param = string.format('[{"cat_id":"%s","ent_id":"%s","site":"%s"}]', tostring(cat), tostring(id), tostring(site))
|
||||||
|
|
||||||
@@ -121,22 +196,13 @@ local function get_episodes_v2(cat, id, site)
|
|||||||
})
|
})
|
||||||
|
|
||||||
if not res.status or res.status ~= 0 then
|
if not res.status or res.status ~= 0 then
|
||||||
msg.error("Failed to fetch episodesv2: " .. (res.stderr or "unknown error"))
|
msg.warn("Failed to fetch episodesv2: " .. (res.stderr or "unknown error"))
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local data_text = res.stdout or ""
|
local parsed = utils.parse_json(res.stdout)
|
||||||
-- 兼容 JSONP 和 纯 JSON:提取最外层括号内 JSON
|
|
||||||
local json_payload = data_text
|
|
||||||
local first_paren = data_text:find('%(')
|
|
||||||
local last_paren = data_text:match('.*()%)')
|
|
||||||
if first_paren and last_paren and last_paren > first_paren then
|
|
||||||
json_payload = data_text:sub(first_paren + 1, last_paren - 1)
|
|
||||||
end
|
|
||||||
|
|
||||||
local parsed = utils.parse_json(json_payload)
|
|
||||||
if not parsed then
|
if not parsed then
|
||||||
msg.error("episodesv2: 解析返回失败: " .. (res.stdout or ""))
|
msg.warn("episodesv2: 解析返回失败: " .. (res.stdout or ""))
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -144,15 +210,10 @@ local function get_episodes_v2(cat, id, site)
|
|||||||
if parsed.code == 0 and parsed.data and #parsed.data > 0 then
|
if parsed.code == 0 and parsed.data and #parsed.data > 0 then
|
||||||
local seriesHTML = parsed.data[1] and parsed.data[1].seriesHTML
|
local seriesHTML = parsed.data[1] and parsed.data[1].seriesHTML
|
||||||
if seriesHTML and seriesHTML.seriesPlaylinks then
|
if seriesHTML and seriesHTML.seriesPlaylinks then
|
||||||
for i, ep in ipairs(seriesHTML.seriesPlaylinks) do
|
local rows = build_episode_rows(seriesHTML.seriesPlaylinks)
|
||||||
local episode_url = nil
|
if rows then
|
||||||
if type(ep) == 'string' then
|
for _, r in ipairs(rows) do
|
||||||
episode_url = ep
|
table.insert(episodes, { index = tonumber(r.index), url = r.url })
|
||||||
elseif type(ep) == 'table' and ep.url then
|
|
||||||
episode_url = ep.url
|
|
||||||
end
|
|
||||||
if episode_url and episode_url ~= '' then
|
|
||||||
table.insert(episodes, { index = i, url = episode_url })
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -170,7 +231,7 @@ function get_details(class, id, site, title, year, number, episodenum)
|
|||||||
local menu_title = "剧集信息"
|
local menu_title = "剧集信息"
|
||||||
local footnote = "使用 / 打开筛选"
|
local footnote = "使用 / 打开筛选"
|
||||||
if uosc_available and not episodenum then
|
if uosc_available and not episodenum then
|
||||||
update_menu_uosc(menu_type, menu_title, message, footnote)
|
update_menu_uosc(menu_type, menu_title, message, footnote, nil, nil, "spinner")
|
||||||
else
|
else
|
||||||
show_message(message, 3)
|
show_message(message, 3)
|
||||||
end
|
end
|
||||||
@@ -188,59 +249,57 @@ function get_details(class, id, site, title, year, number, episodenum)
|
|||||||
|
|
||||||
local items = {}
|
local items = {}
|
||||||
local episodes = nil
|
local episodes = nil
|
||||||
|
local episode_rows = nil
|
||||||
|
|
||||||
|
-- 优先尝试使用搜索时缓存的 seriesPlaylinks(若存在且站点匹配)
|
||||||
if cat == 2 or cat == 4 then
|
if cat == 2 or cat == 4 then
|
||||||
episodes = get_episodes_v2(cat, id, site)
|
local cid = tostring(id)
|
||||||
|
local cached = cached_series_playlinks[cid]
|
||||||
|
if cached and cached.seriesPlaylinks and cached.seriesSite and tostring(cached.seriesSite) == tostring(site) then
|
||||||
|
local rows = build_episode_rows(cached.seriesPlaylinks, cached.playlink)
|
||||||
|
if rows then
|
||||||
|
episode_rows = rows
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 统一构建 episode_rows:优先使用 episodesv2 返回的数据,否则使用 v1/detail
|
-- 若未命中缓存,则继续使用 episodesv2/v1 的原有流程
|
||||||
local episode_rows = nil
|
if not episode_rows then
|
||||||
if episodes then
|
if cat == 2 or cat == 4 then
|
||||||
episode_rows = {}
|
episodes = get_episodes_v2(cat, id, site)
|
||||||
for _, ep in ipairs(episodes) do
|
|
||||||
table.insert(episode_rows, { index = tostring(ep.index), url = ep.url })
|
|
||||||
end
|
|
||||||
else
|
|
||||||
if not number and cat ~= 0 then
|
|
||||||
number = get_number(cat, id, site)
|
|
||||||
end
|
|
||||||
if not number or cat == 0 then
|
|
||||||
local message = "无结果"
|
|
||||||
if uosc_available and not episodenum then
|
|
||||||
update_menu_uosc(menu_type, menu_title, message, footnote)
|
|
||||||
else
|
|
||||||
show_message(message, 3)
|
|
||||||
end
|
|
||||||
msg.verbose("无结果")
|
|
||||||
return
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local url = string.format("https://api.web.360kan.com/v1/detail?cat=%s&id=%s&start=1&end=%s&site=%s",
|
-- 统一构建 episode_rows:优先使用 episodesv2 返回的数据,否则使用 v1/detail
|
||||||
cat, id, number, site)
|
if episodes then
|
||||||
|
|
||||||
local cmd = { "curl", "-s", url }
|
|
||||||
local res = mp.command_native({
|
|
||||||
name = "subprocess",
|
|
||||||
args = cmd,
|
|
||||||
capture_stdout = true,
|
|
||||||
capture_stderr = true,
|
|
||||||
})
|
|
||||||
|
|
||||||
if not res.status or res.status ~= 0 then
|
|
||||||
local message = "无结果"
|
|
||||||
if uosc_available and not episodenum then
|
|
||||||
update_menu_uosc(menu_type, menu_title, message, footnote)
|
|
||||||
else
|
|
||||||
show_message(message, 3)
|
|
||||||
end
|
|
||||||
msg.verbose("无结果")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
local result = utils.parse_json(res.stdout)
|
|
||||||
if result and result.data and result.data.allepidetail and result.data.allepidetail[site] then
|
|
||||||
episode_rows = {}
|
episode_rows = {}
|
||||||
for _, it in ipairs(result.data.allepidetail[site]) do
|
for _, ep in ipairs(episodes) do
|
||||||
table.insert(episode_rows, { index = tostring(it.playlink_num), url = it.url })
|
table.insert(episode_rows, { index = tostring(ep.index), url = ep.url })
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if not number and cat ~= 0 then
|
||||||
|
number = get_number(cat, id, site)
|
||||||
|
end
|
||||||
|
if not number or cat == 0 then
|
||||||
|
local message = "无结果"
|
||||||
|
if uosc_available and not episodenum then
|
||||||
|
update_menu_uosc(menu_type, menu_title, message, footnote)
|
||||||
|
else
|
||||||
|
show_message(message, 3)
|
||||||
|
end
|
||||||
|
msg.verbose("无结果")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
episode_rows = get_episodes_v1(cat, id, site, number)
|
||||||
|
if not episode_rows or #episode_rows == 0 then
|
||||||
|
local message = "无结果"
|
||||||
|
if uosc_available and not episodenum then
|
||||||
|
update_menu_uosc(menu_type, menu_title, message, footnote)
|
||||||
|
else
|
||||||
|
show_message(message, 3)
|
||||||
|
end
|
||||||
|
msg.verbose("无结果")
|
||||||
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -325,6 +384,18 @@ local function search_query(query, class, menu)
|
|||||||
if result and result.data.longData and result.data.longData.rows then
|
if result and result.data.longData and result.data.longData.rows then
|
||||||
for _, item in ipairs(result.data.longData.rows) do
|
for _, item in ipairs(result.data.longData.rows) do
|
||||||
if item.playlinks then
|
if item.playlinks then
|
||||||
|
-- 如果搜索结果中包含 seriesPlaylinks,则缓存它(使用 en_id 作为 key)
|
||||||
|
if item.seriesPlaylinks and item.en_id then
|
||||||
|
local playlink = nil
|
||||||
|
if item.playlinks and item.seriesSite then
|
||||||
|
playlink = item.playlinks[item.seriesSite]
|
||||||
|
end
|
||||||
|
cached_series_playlinks[tostring(item.en_id)] = {
|
||||||
|
seriesPlaylinks = item.seriesPlaylinks,
|
||||||
|
seriesSite = item.seriesSite,
|
||||||
|
playlink = playlink,
|
||||||
|
}
|
||||||
|
end
|
||||||
for source_name, source_id in pairs(Source) do
|
for source_name, source_id in pairs(Source) do
|
||||||
if item.playlinks[source_id] then
|
if item.playlinks[source_id] then
|
||||||
table.insert(items, {
|
table.insert(items, {
|
||||||
@@ -375,7 +446,7 @@ function query_extra(name, class)
|
|||||||
}
|
}
|
||||||
menu.cmd = { "script-message-to", mp.get_script_name(), "search-anime-event" }
|
menu.cmd = { "script-message-to", mp.get_script_name(), "search-anime-event" }
|
||||||
if uosc_available then
|
if uosc_available then
|
||||||
update_menu_uosc(menu.type, menu.title, message, menu.footnote, menu.cmd, name)
|
update_menu_uosc(menu.type, menu.title, message, menu.footnote, menu.cmd, name, "spinner")
|
||||||
else
|
else
|
||||||
show_message(message, 30)
|
show_message(message, 30)
|
||||||
end
|
end
|
||||||
|
|||||||
+74
-238
@@ -1,4 +1,4 @@
|
|||||||
VERSION = "2.0.0"
|
VERSION = "2.2.0"
|
||||||
|
|
||||||
mp.commandv('script-message', 'uosc_danmaku-version', VERSION)
|
mp.commandv('script-message', 'uosc_danmaku-version', VERSION)
|
||||||
|
|
||||||
@@ -21,6 +21,13 @@ require("modules/update")
|
|||||||
require("apis/dandanplay")
|
require("apis/dandanplay")
|
||||||
require('apis/extra')
|
require('apis/extra')
|
||||||
|
|
||||||
|
require("sites/bilibili")
|
||||||
|
require("sites/bahamut")
|
||||||
|
require("sites/iqiyi")
|
||||||
|
require("sites/mgtv")
|
||||||
|
require("sites/tencentvideo")
|
||||||
|
require("sites/youku")
|
||||||
|
|
||||||
DANMAKU_PATH = os.getenv("TEMP") or "/tmp/"
|
DANMAKU_PATH = os.getenv("TEMP") or "/tmp/"
|
||||||
HISTORY_PATH = mp.command_native({"expand-path", options.history_path})
|
HISTORY_PATH = mp.command_native({"expand-path", options.history_path})
|
||||||
PID = utils.getpid()
|
PID = utils.getpid()
|
||||||
@@ -30,6 +37,10 @@ DELAY_PROPERTY = string.format("user-data/%s/danmaku-delay", mp.get_script_name(
|
|||||||
mp.set_property_native(DELAY_PROPERTY, 0)
|
mp.set_property_native(DELAY_PROPERTY, 0)
|
||||||
HAS_DANMAKU = string.format("user-data/%s/has-danmaku", mp.get_script_name())
|
HAS_DANMAKU = string.format("user-data/%s/has-danmaku", mp.get_script_name())
|
||||||
mp.set_property_bool(HAS_DANMAKU, false)
|
mp.set_property_bool(HAS_DANMAKU, false)
|
||||||
|
DANMAKU_SWITCH_ON = string.format("user-data/%s/danmaku-switch-on", mp.get_script_name())
|
||||||
|
mp.set_property_bool(DANMAKU_SWITCH_ON, false)
|
||||||
|
DANMAKU_COUNT = string.format("user-data/%s/danmaku-count", mp.get_script_name())
|
||||||
|
mp.set_property_native(DANMAKU_COUNT, 0)
|
||||||
KEY = table_to_zero_indexed({
|
KEY = table_to_zero_indexed({
|
||||||
0x00,0x01,0x02,0x03,0x04,
|
0x00,0x01,0x02,0x03,0x04,
|
||||||
0x05,0x06,0x07,0x08,0x09,
|
0x05,0x06,0x07,0x08,0x09,
|
||||||
@@ -92,7 +103,7 @@ end
|
|||||||
|
|
||||||
function set_danmaku_button()
|
function set_danmaku_button()
|
||||||
if get_danmaku_visibility() then
|
if get_danmaku_visibility() then
|
||||||
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", "on")
|
toggle_danmaku_switch("on")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -105,51 +116,7 @@ function show_loaded(init)
|
|||||||
else
|
else
|
||||||
show_message("弹幕加载成功,共计" .. #COMMENTS .. "条弹幕", 3)
|
show_message("弹幕加载成功,共计" .. #COMMENTS .. "条弹幕", 3)
|
||||||
end
|
end
|
||||||
end
|
mp.set_property_native(DANMAKU_COUNT, #COMMENTS)
|
||||||
|
|
||||||
local function get_cid()
|
|
||||||
local cid, danmaku_id = nil, nil
|
|
||||||
local tracks = mp.get_property_native("track-list")
|
|
||||||
for _, track in ipairs(tracks) do
|
|
||||||
if track["lang"] == "danmaku" then
|
|
||||||
cid = track["external-filename"]:match("/(%d-)%.xml$")
|
|
||||||
danmaku_id = track["id"]
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return cid, danmaku_id
|
|
||||||
end
|
|
||||||
|
|
||||||
local function extract_between_colons(input_string)
|
|
||||||
local start_index = 0
|
|
||||||
local end_index = 0
|
|
||||||
local count = 0
|
|
||||||
for i = 1, #input_string do
|
|
||||||
if input_string:sub(i, i) == ":" then
|
|
||||||
count = count + 1
|
|
||||||
if count == 2 then
|
|
||||||
start_index = i
|
|
||||||
elseif count == 3 then
|
|
||||||
end_index = i
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if start_index > 0 and end_index > 0 then
|
|
||||||
return input_string:sub(start_index + 1, end_index - 1)
|
|
||||||
else
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function get_type_from_position(position)
|
|
||||||
if position == 0 then
|
|
||||||
return 1
|
|
||||||
end
|
|
||||||
if position == 1 then
|
|
||||||
return 4
|
|
||||||
end
|
|
||||||
return 5
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 获取指定时间的延迟
|
-- 获取指定时间的延迟
|
||||||
@@ -287,10 +254,12 @@ local function set_danmaku_delay(dly, time, specific_source)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if dly == 0 then
|
if not specific_source then
|
||||||
DELAY = 0
|
if dly == 0 then
|
||||||
else
|
DELAY = 0
|
||||||
DELAY = DELAY + dly
|
else
|
||||||
|
DELAY = DELAY + dly
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if ENABLED and COMMENTS ~= nil then
|
if ENABLED and COMMENTS ~= nil then
|
||||||
@@ -310,8 +279,14 @@ local function set_danmaku_delay(dly, time, specific_source)
|
|||||||
rebuild_convert_timer = nil
|
rebuild_convert_timer = nil
|
||||||
end)
|
end)
|
||||||
|
|
||||||
show_message('设置弹幕延迟: ' .. string.format("%.1f", DELAY + 1e-10) .. ' s')
|
if specific_source then
|
||||||
mp.set_property_native(DELAY_PROPERTY, DELAY)
|
local source = DANMAKU.sources[specific_source]
|
||||||
|
local source_delay = get_delay_for_time(source and source.delay_segments, time or 0)
|
||||||
|
show_message('设置弹幕源延迟: ' .. string.format("%.1f", source_delay + 1e-10) .. ' s')
|
||||||
|
else
|
||||||
|
show_message('设置弹幕延迟: ' .. string.format("%.1f", DELAY + 1e-10) .. ' s')
|
||||||
|
mp.set_property_native(DELAY_PROPERTY, DELAY)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function clear_source()
|
local function clear_source()
|
||||||
@@ -430,11 +405,6 @@ function add_source_to_history(add_url, add_source)
|
|||||||
local record = history[path]["sources"][add_url]
|
local record = history[path]["sources"][add_url]
|
||||||
record.from = add_source.from or "user_custom"
|
record.from = add_source.from or "user_custom"
|
||||||
record.blocked = add_source.blocked or false
|
record.blocked = add_source.blocked or false
|
||||||
if record.from == "api_server" then
|
|
||||||
record.api_server = add_source.api_server or options.api_server
|
|
||||||
else
|
|
||||||
record.api_server = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
local delay_segments = shallow_copy(add_source.delay_segments or {})
|
local delay_segments = shallow_copy(add_source.delay_segments or {})
|
||||||
if #delay_segments > 0 then
|
if #delay_segments > 0 then
|
||||||
@@ -460,6 +430,7 @@ function read_danmaku_source_record(path)
|
|||||||
|
|
||||||
local history = utils.parse_json(history_json) or {}
|
local history = utils.parse_json(history_json) or {}
|
||||||
local record = history[path]
|
local record = history[path]
|
||||||
|
|
||||||
if not record or not record.sources then return end
|
if not record or not record.sources then return end
|
||||||
|
|
||||||
local sources = record.sources
|
local sources = record.sources
|
||||||
@@ -489,7 +460,6 @@ function read_danmaku_source_record(path)
|
|||||||
blocked = blocked,
|
blocked = blocked,
|
||||||
delay_segments = delay_segments,
|
delay_segments = delay_segments,
|
||||||
from_history = true,
|
from_history = true,
|
||||||
api_server = data.api_server,
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
@@ -519,7 +489,6 @@ function read_danmaku_source_record(path)
|
|||||||
blocked = blocked,
|
blocked = blocked,
|
||||||
delay_segments = delay_segments,
|
delay_segments = delay_segments,
|
||||||
from_history = true,
|
from_history = true,
|
||||||
api_server = record.api_server,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
upgraded_sources[source] = shallow_copy(DANMAKU.sources[source])
|
upgraded_sources[source] = shallow_copy(DANMAKU.sources[source])
|
||||||
@@ -532,14 +501,48 @@ function read_danmaku_source_record(path)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function get_save_danmaku_output(path, filename)
|
||||||
|
if not path or not filename then return nil end
|
||||||
|
|
||||||
|
local custom_dir = options.save_danmaku_path ~= "" and
|
||||||
|
mp.command_native({"expand-path", options.save_danmaku_path}) or nil
|
||||||
|
local is_url = is_protocol(path)
|
||||||
|
local mode = options.save_danmaku_path_mode
|
||||||
|
local dir = nil
|
||||||
|
|
||||||
|
if mode ~= "url" and mode ~= "all" then
|
||||||
|
mode = "local"
|
||||||
|
end
|
||||||
|
|
||||||
|
if custom_dir and (mode == "all" or (mode == "url" and is_url) or (mode == "local" and not is_url)) then
|
||||||
|
local output_name = sanitize_filename(filename)
|
||||||
|
if not is_url then
|
||||||
|
dir = get_parent_directory(path)
|
||||||
|
local _, parent_name = dir and utils.split_path(dir:sub(1, -2))
|
||||||
|
if parent_name and parent_name ~= "" then
|
||||||
|
output_name = sanitize_filename(parent_name .. "_" .. filename)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return utils.join_path(custom_dir, output_name .. ".xml")
|
||||||
|
end
|
||||||
|
|
||||||
|
if is_url then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
dir = get_parent_directory(path)
|
||||||
|
if not dir then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return utils.join_path(dir, filename .. ".xml")
|
||||||
|
end
|
||||||
|
|
||||||
-- 视频播放时保存弹幕
|
-- 视频播放时保存弹幕
|
||||||
function save_danmaku(not_forced)
|
function save_danmaku(not_forced)
|
||||||
local path = mp.get_property("path")
|
local path = mp.get_property("path")
|
||||||
local dir = get_parent_directory(path) or ""
|
local filename = is_protocol(path) and mp.get_property("media-title") or mp.get_property('filename/no-ext')
|
||||||
local filename = mp.get_property('filename/no-ext')
|
local danmaku_out = get_save_danmaku_output(path, filename)
|
||||||
local danmaku_out = utils.join_path(dir, filename .. ".xml")
|
if not danmaku_out or (not file_exists(danmaku_out)
|
||||||
-- 排除网络播放场景
|
|
||||||
if not path or is_protocol(path) or (not file_exists(danmaku_out)
|
|
||||||
and not is_writable(danmaku_out)) then
|
and not is_writable(danmaku_out)) then
|
||||||
show_message("此弹幕文件不支持保存至本地")
|
show_message("此弹幕文件不支持保存至本地")
|
||||||
msg.warn("此弹幕文件不支持保存至本地")
|
msg.warn("此弹幕文件不支持保存至本地")
|
||||||
@@ -561,176 +564,6 @@ function load_danmaku(from_menu, no_osd)
|
|||||||
render_danmaku(from_menu, no_osd)
|
render_danmaku(from_menu, no_osd)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 为 bilibli 网站的视频播放加载弹幕
|
|
||||||
function load_danmaku_for_bilibili(path)
|
|
||||||
local cid, danmaku_id = get_cid()
|
|
||||||
if danmaku_id ~= nil then
|
|
||||||
mp.commandv('sub-remove', danmaku_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
if cid == nil then
|
|
||||||
cid = mp.get_opt('cid')
|
|
||||||
if not cid then
|
|
||||||
local patterns = {
|
|
||||||
"bilivideo%.c[nom]+.*/resource/(%d+)%D+.*",
|
|
||||||
"bilivideo%.c[nom]+.*/(%d+)-%d+-%d+%..*%?",
|
|
||||||
}
|
|
||||||
local urls = {
|
|
||||||
path,
|
|
||||||
mp.get_property("stream-open-filename", ''),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, pattern in ipairs(patterns) do
|
|
||||||
for _, url in ipairs(urls) do
|
|
||||||
if url:find(pattern) then
|
|
||||||
cid = url:match(pattern)
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if cid == nil and path:match("/video/BV.-") then
|
|
||||||
if path:match("video/BV.-/.*") then
|
|
||||||
path = path:gsub("/[^/]+$", "")
|
|
||||||
end
|
|
||||||
add_danmaku_source_online(path, true)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
if cid ~= nil then
|
|
||||||
local url = "https://comment.bilibili.com/" .. cid .. ".xml"
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
if options.cookie_file and options.cookie_file ~= "" then
|
|
||||||
table.insert(arg, '-b')
|
|
||||||
table.insert(arg, mp.command_native({"expand-path", options.cookie_file}))
|
|
||||||
end
|
|
||||||
|
|
||||||
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
|
|
||||||
save_danmaku_downloaded(path, danmaku_xml)
|
|
||||||
load_danmaku(true)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- 为 bahamut 网站的视频播放加载弹幕
|
|
||||||
function load_danmaku_for_bahamut(path)
|
|
||||||
local path = path:gsub('%%(%x%x)', hex_to_char)
|
|
||||||
local sn = extract_between_colons(path)
|
|
||||||
if sn == nil then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
local url = "https://ani.gamer.com.tw/ajax/danmuGet.php"
|
|
||||||
local temp_file = "bahamut-" .. PID .. ".json"
|
|
||||||
local danmaku_json = utils.join_path(DANMAKU_PATH, temp_file)
|
|
||||||
local arg = {
|
|
||||||
"curl",
|
|
||||||
"-X",
|
|
||||||
"POST",
|
|
||||||
"-d",
|
|
||||||
"sn=" .. sn,
|
|
||||||
"-L",
|
|
||||||
"-s",
|
|
||||||
"--user-agent",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36",
|
|
||||||
"--header",
|
|
||||||
"Origin: https://ani.gamer.com.tw",
|
|
||||||
"--header",
|
|
||||||
"Content-Type: application/x-www-form-urlencoded;charset=utf-8",
|
|
||||||
"--header",
|
|
||||||
"Accept: application/json",
|
|
||||||
"--header",
|
|
||||||
"Authority: ani.gamer.com.tw",
|
|
||||||
"--output",
|
|
||||||
danmaku_json,
|
|
||||||
url,
|
|
||||||
}
|
|
||||||
|
|
||||||
if options.proxy ~= "" then
|
|
||||||
table.insert(arg, '-x')
|
|
||||||
table.insert(arg, options.proxy)
|
|
||||||
end
|
|
||||||
|
|
||||||
if options.cookie_file and options.cookie_file ~= "" then
|
|
||||||
table.insert(arg, '-b')
|
|
||||||
table.insert(arg, mp.command_native({"expand-path", options.cookie_file}))
|
|
||||||
end
|
|
||||||
|
|
||||||
call_cmd_async(arg, function(error)
|
|
||||||
async_running = false
|
|
||||||
if error then
|
|
||||||
show_message("HTTP 请求失败,打开控制台查看详情", 5)
|
|
||||||
msg.error(error)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
if not file_exists(danmaku_json) then
|
|
||||||
url = "https://ani.gamer.com.tw/animeVideo.php?sn=" .. sn
|
|
||||||
ENABLED = true
|
|
||||||
add_danmaku_source_online(url, true)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
local comments_json = read_file(danmaku_json)
|
|
||||||
os.remove(danmaku_json)
|
|
||||||
local comments = utils.parse_json(comments_json)
|
|
||||||
if not comments then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
local output_table = {}
|
|
||||||
for _, comment in ipairs(comments) do
|
|
||||||
local color = hex_to_int_color(comment["color"])
|
|
||||||
local mode = get_type_from_position(comment["position"])
|
|
||||||
local time = tonumber(comment["time"]) / 10
|
|
||||||
local c_param = string.format("%s,%s,%s,25,,,", time, color, mode)
|
|
||||||
table.insert(output_table, {
|
|
||||||
c = c_param,
|
|
||||||
m = comment["text"]
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
local final_json_str = utils.format_json(output_table)
|
|
||||||
|
|
||||||
temp_file = "danmaku-" .. PID .. DANMAKU.count .. ".json"
|
|
||||||
local json_filename = utils.join_path(DANMAKU_PATH, temp_file)
|
|
||||||
DANMAKU.count = DANMAKU.count + 1
|
|
||||||
|
|
||||||
local json_file = io.open(json_filename, "w")
|
|
||||||
if json_file then
|
|
||||||
json_file:write(final_json_str)
|
|
||||||
json_file:close()
|
|
||||||
end
|
|
||||||
|
|
||||||
if file_exists(json_filename) then
|
|
||||||
save_danmaku_downloaded(
|
|
||||||
"https://ani.gamer.com.tw/animeVideo.php?sn=" .. sn,
|
|
||||||
json_filename)
|
|
||||||
load_danmaku(true)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
function load_danmaku_for_url(path)
|
function load_danmaku_for_url(path)
|
||||||
if path:find('bilibili.com') or path:find('bilivideo.c[nom]+') then
|
if path:find('bilibili.com') or path:find('bilivideo.c[nom]+') then
|
||||||
load_danmaku_for_bilibili(path)
|
load_danmaku_for_bilibili(path)
|
||||||
@@ -776,6 +609,7 @@ function auto_load_danmaku(path, dir, filename, number)
|
|||||||
local history_id = history_dir.episodeId
|
local history_id = history_dir.episodeId
|
||||||
local history_fname = history_dir.fname
|
local history_fname = history_dir.fname
|
||||||
local history_extra = history_dir.extra
|
local history_extra = history_dir.extra
|
||||||
|
local history_api_server = history_dir.api_server
|
||||||
local playing_number = nil
|
local playing_number = nil
|
||||||
|
|
||||||
if history_fname then
|
if history_fname then
|
||||||
@@ -794,6 +628,7 @@ function auto_load_danmaku(path, dir, filename, number)
|
|||||||
if playing_number ~= nil then
|
if playing_number ~= nil then
|
||||||
local x = playing_number - history_number --获取集数差值
|
local x = playing_number - history_number --获取集数差值
|
||||||
DANMAKU.episode = episode_number and string.format("第%s话", episode_number + x) or history_dir.episodeTitle
|
DANMAKU.episode = episode_number and string.format("第%s话", episode_number + x) or history_dir.episodeTitle
|
||||||
|
DANMAKU.api_server = history_api_server or nil
|
||||||
show_message("自动加载上次匹配的弹幕", 3)
|
show_message("自动加载上次匹配的弹幕", 3)
|
||||||
msg.verbose("自动加载上次匹配的弹幕")
|
msg.verbose("自动加载上次匹配的弹幕")
|
||||||
if history_id then
|
if history_id then
|
||||||
@@ -881,7 +716,7 @@ mp.register_event("file-loaded", function()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if ENABLED and COMMENTS == nil and not async_running then
|
if ENABLED and COMMENTS == nil and not is_async_running() then
|
||||||
init(path)
|
init(path)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
@@ -914,7 +749,8 @@ end)
|
|||||||
mp.register_script_message("show_danmaku_keyboard", function()
|
mp.register_script_message("show_danmaku_keyboard", function()
|
||||||
ENABLED = not ENABLED
|
ENABLED = not ENABLED
|
||||||
if ENABLED then
|
if ENABLED then
|
||||||
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", "on")
|
toggle_danmaku_switch("on")
|
||||||
|
|
||||||
if COMMENTS == nil then
|
if COMMENTS == nil then
|
||||||
show_message("加载弹幕初始化...", 3)
|
show_message("加载弹幕初始化...", 3)
|
||||||
set_danmaku_visibility(true)
|
set_danmaku_visibility(true)
|
||||||
@@ -926,7 +762,7 @@ mp.register_script_message("show_danmaku_keyboard", function()
|
|||||||
end
|
end
|
||||||
else
|
else
|
||||||
show_message("关闭弹幕", 2)
|
show_message("关闭弹幕", 2)
|
||||||
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", "off")
|
toggle_danmaku_switch("off")
|
||||||
hide_danmaku_func()
|
hide_danmaku_func()
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
--[[lit-meta
|
||||||
|
name = 'TohruMKDM/inflate'
|
||||||
|
version = '2.0.0'
|
||||||
|
homepage = 'https://github.com/TohruMKDM/lua-inflate'
|
||||||
|
description = 'ZIP archive inflation in pure Lua.'
|
||||||
|
tags = {'zlib', 'inflate', 'inflation', 'compression'}
|
||||||
|
license = 'MIT'
|
||||||
|
author = {name = 'Tohru~ (トール)', email = 'admin@ikaros.pw'}
|
||||||
|
contributors = {'zerkman', 'samhocevar'}
|
||||||
|
]]
|
||||||
|
|
||||||
|
---@diagnostic disable-next-line: undefined-global
|
||||||
|
local bitOp = bit32 or bit
|
||||||
|
if not bitOp then
|
||||||
|
error('This library requires bit operations to work property.')
|
||||||
|
end
|
||||||
|
|
||||||
|
local rshift, lshift, band, bxor, bnot = bitOp.rshift, bitOp.lshift, bitOp.band, bitOp.bxor, bitOp.bnot
|
||||||
|
local byte, char, sub, find = string.byte, string.char, string.sub, string.find
|
||||||
|
local concat, unpack = table.concat, unpack or table.unpack
|
||||||
|
local min = math.min
|
||||||
|
|
||||||
|
local ORDER = {17, 18, 19, 1, 9, 8, 10, 7, 11, 6, 12, 5, 13, 4, 14, 3, 15, 2, 16}
|
||||||
|
local CRC32 = {[0] = 0, 1996959894, -301047508, -1727442502, 124634137, 1886057615, -379345611, -1637575261, 249268274, 2044508324, -522852066, -1747789432, 162941995, 2125561021, -407360249, -1866523247, 498536548, 1789927666, -205950648, -2067906082, 450548861, 1843258603, -187386543, -2083289657, 325883990, 1684777152, -43845254,-1973040660, 335633487, 1661365465, -99664541, -1928851979, 997073096, 1281953886, -715111964, -1570279054, 1006888145, 1258607687, -770865667, -1526024853, 901097722, 1119000684, -608450090, -1396901568, 853044451, 1172266101, -589951537, -1412350631, 651767980, 1373503546, -925412992, -1076862698, 565507253, 1454621731, -809855591, -1195530993, 671266974, 1594198024, -972236366, -1324619484, 795835527, 1483230225, -1050600021, -1234817731, 1994146192, 31158534, -1731059524, -271249366, 1907459465, 112637215, -1614814043, -390540237, 2013776290, 251722036, -1777751922, -519137256, 2137656763, 141376813, -1855689577, -429695999, 1802195444, 476864866, -2056965928, -228458418, 1812370925, 453092731, -2113342271, -183516073, 1706088902, 314042704, -1950435094, -54949764, 1658658271, 366619977, -1932296973, -69972891, 1303535960, 984961486, -1547960204, -725929758, 1256170817, 1037604311, -1529756563, -740887301, 1131014506, 879679996, -1385723834, -631195440, 1141124467, 855842277, -1442165665, -586318647, 1342533948, 654459306, -1106571248, -921952122, 1466479909, 544179635, -1184443383, -832445281, 1591671054, 702138776, -1328506846, -942167884, 1504918807, 783551873, -1212326853, -1061524307, -306674912, -1698712650, 62317068, 1957810842, -355121351, -1647151185, 81470997,1943803523, -480048366, -1805370492, 225274430, 2053790376, -468791541, -1828061283, 167816743, 2097651377, -267414716, -2029476910, 503444072, 1762050814, -144550051, -2140837941, 426522225, 1852507879, -19653770, -1982649376, 282753626, 1742555852, -105259153, -1900089351, 397917763, 1622183637, -690576408, -1580100738, 953729732, 1340076626, -776247311, -1497606297, 1068828381, 1219638859, -670225446, -1358292148, 906185462, 1090812512, -547295293, -1469587627, 829329135, 1181335161, -882789492, -1134132454, 628085408, 1382605366, -871598187, -1156888829, 570562233, 1426400815, -977650754, -1296233688, 733239954, 1555261956, -1026031705, -1244606671, 752459403, 1541320221, -1687895376, -328994266, 1969922972, 40735498, -1677130071, -351390145, 1913087877, 83908371, -1782625662, -491226604, 2075208622, 213261112, -1831694693, -438977011, 2094854071, 198958881, -2032938284, -237706686, 1759359992, 534414190, -2118248755, -155638181, 1873836001, 414664567, -2012718362, -15766928, 1711684554, 285281116, -1889165569, -127750551, 1634467795, 376229701, -1609899400, -686959890, 1308918612, 956543938, -1486412191, -799009033, 1231636301, 1047427035, -1362007478, -640263460, 1088359270, 936918000, -1447252397, -558129467, 1202900863, 817233897, -1111625188, -893730166, 1404277552, 615818150, -1160759803, -841546093, 1423857449, 601450431, -1285129682, -1000256840, 1567103746, 711928724, -1274298825, -1022587231, 1510334235, 755167117}
|
||||||
|
local NBT = {2, 3, 7}
|
||||||
|
local CNT = {144, 112, 24, 8}
|
||||||
|
local DPT = {8, 9, 7, 8}
|
||||||
|
local STATIC_HUFFMAN = {[0] = 5, 261, 133, 389, 69, 325, 197, 453, 37, 293, 165, 421, 101, 357, 229, 485, 21, 277, 149, 405, 85, 341, 213, 469, 53, 309, 181, 437, 117, 373, 245, 501}
|
||||||
|
local STATIC_BITS = 5
|
||||||
|
local CHUNK_SIZE = 4096
|
||||||
|
|
||||||
|
local function generateCrc32(input)
|
||||||
|
local crc = -1
|
||||||
|
for i = 1, #input do
|
||||||
|
local c = byte(input, i)
|
||||||
|
crc = bxor(CRC32[bxor(c, band(crc, 255))], rshift(crc, 8))
|
||||||
|
end
|
||||||
|
crc = bnot(crc)
|
||||||
|
if crc < 0 then
|
||||||
|
crc = crc + 4294967296
|
||||||
|
end
|
||||||
|
return crc
|
||||||
|
end
|
||||||
|
|
||||||
|
local function flushBits(stream, int)
|
||||||
|
stream.bits = rshift(stream.bits, int)
|
||||||
|
stream.count = stream.count - int
|
||||||
|
end
|
||||||
|
|
||||||
|
local function peekBits(stream, int)
|
||||||
|
local buffer, bits, count, position = stream.buffer, stream.bits, stream.count, stream.position
|
||||||
|
while count < int do
|
||||||
|
bits = bits + lshift(byte(buffer, position), count)
|
||||||
|
position = position + 1
|
||||||
|
count = count + 8
|
||||||
|
end
|
||||||
|
stream.bits = bits
|
||||||
|
stream.position = position
|
||||||
|
stream.count = count
|
||||||
|
return band(bits, lshift(1, int) - 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getBits(stream, int)
|
||||||
|
local result = peekBits(stream, int)
|
||||||
|
stream.bits = rshift(stream.bits, int)
|
||||||
|
stream.count = stream.count - int
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getElement(stream, hufftable, int)
|
||||||
|
local element = hufftable[peekBits(stream, int)]
|
||||||
|
local length = band(element, 15)
|
||||||
|
local result = rshift(element, 4)
|
||||||
|
stream.bits = rshift(stream.bits, length)
|
||||||
|
stream.count = stream.count - length
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local function huffman(depths)
|
||||||
|
local size = #depths
|
||||||
|
local blocks, codes, hufftable = {[0] = 0}, {}, {}
|
||||||
|
local bits, code = 1, 0
|
||||||
|
for i = 1, size do
|
||||||
|
local depth = depths[i]
|
||||||
|
if depth > bits then
|
||||||
|
bits = depth
|
||||||
|
end
|
||||||
|
blocks[depth] = (blocks[depth] or 0) + 1
|
||||||
|
end
|
||||||
|
for i = 1, bits do
|
||||||
|
code = (code + (blocks[i - 1] or 0)) * 2
|
||||||
|
codes[i] = code
|
||||||
|
end
|
||||||
|
for i = 1, size do
|
||||||
|
local depth = depths[i]
|
||||||
|
if depth > 0 then
|
||||||
|
local element = (i - 1) * 16 + depth
|
||||||
|
local rcode = 0
|
||||||
|
for j = 1, depth do
|
||||||
|
rcode = rcode + lshift(band(1, rshift(codes[depth], j - 1)), depth - j)
|
||||||
|
end
|
||||||
|
for j = 0, 2 ^ bits - 1, 2 ^ depth do
|
||||||
|
hufftable[j + rcode] = element
|
||||||
|
end
|
||||||
|
codes[depth] = codes[depth] + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return hufftable, bits
|
||||||
|
end
|
||||||
|
|
||||||
|
local function loop(output, stream, litTable, litBits, distTable, distBits)
|
||||||
|
local index = #output + 1
|
||||||
|
local lit
|
||||||
|
repeat
|
||||||
|
lit = getElement(stream, litTable, litBits)
|
||||||
|
if lit < 256 then
|
||||||
|
output[index] = lit
|
||||||
|
index = index + 1
|
||||||
|
elseif lit > 256 then
|
||||||
|
local bits, size, dist = 0, 3, 1
|
||||||
|
if lit < 265 then
|
||||||
|
size = size + lit - 257
|
||||||
|
elseif lit < 285 then
|
||||||
|
bits = rshift(lit - 261, 2)
|
||||||
|
size = size + lshift(band(lit - 261, 3) + 4, bits)
|
||||||
|
else
|
||||||
|
size = 258
|
||||||
|
end
|
||||||
|
if bits > 0 then
|
||||||
|
size = size + getBits(stream, bits)
|
||||||
|
end
|
||||||
|
local element = getElement(stream, distTable, distBits)
|
||||||
|
if element < 4 then
|
||||||
|
dist = dist + element
|
||||||
|
else
|
||||||
|
bits = rshift(element - 2, 1)
|
||||||
|
dist = dist + lshift(band(element, 1) + 2, bits) + getBits(stream, bits)
|
||||||
|
end
|
||||||
|
local position = index - dist
|
||||||
|
repeat
|
||||||
|
output[index] = output[position]
|
||||||
|
index = index + 1
|
||||||
|
position = position + 1
|
||||||
|
size = size - 1
|
||||||
|
until size == 0
|
||||||
|
end
|
||||||
|
until lit == 256
|
||||||
|
end
|
||||||
|
|
||||||
|
local function dynamic(output, stream)
|
||||||
|
local lit, dist, length = 257 + getBits(stream, 5), 1 + getBits(stream, 5), 4 + getBits(stream, 4)
|
||||||
|
local depths = {}
|
||||||
|
for i = 1, length do
|
||||||
|
depths[ORDER[i]] = getBits(stream, 3)
|
||||||
|
end
|
||||||
|
for i = length + 1, 19 do
|
||||||
|
depths[ORDER[i]] = 0
|
||||||
|
end
|
||||||
|
local lengthTable, lengthBits = huffman(depths)
|
||||||
|
local i = 1
|
||||||
|
local total = lit + dist + 1
|
||||||
|
repeat
|
||||||
|
local element = getElement(stream, lengthTable, lengthBits)
|
||||||
|
if element < 16 then
|
||||||
|
depths[i] = element
|
||||||
|
i = i + 1
|
||||||
|
elseif element < 19 then
|
||||||
|
local int = NBT[element - 15]
|
||||||
|
local count = 0
|
||||||
|
local num = 3 + getBits(stream, int)
|
||||||
|
if element == 16 then
|
||||||
|
count = depths[i - 1]
|
||||||
|
elseif element == 18 then
|
||||||
|
num = num + 8
|
||||||
|
end
|
||||||
|
for _ = 1, num do
|
||||||
|
depths[i] = count
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
until i == total
|
||||||
|
local litDepths, distDepths = {}, {}
|
||||||
|
for j = 1, lit do
|
||||||
|
litDepths[j] = depths[j]
|
||||||
|
end
|
||||||
|
for j = lit + 1, #depths do
|
||||||
|
distDepths[#distDepths + 1] = depths[j]
|
||||||
|
end
|
||||||
|
local litTable, litBits = huffman(litDepths)
|
||||||
|
local distTable, distBits = huffman(distDepths)
|
||||||
|
loop(output, stream, litTable, litBits, distTable, distBits)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function static(output, stream)
|
||||||
|
local depths = {}
|
||||||
|
for i = 1, 4 do
|
||||||
|
local depth = DPT[i]
|
||||||
|
for _ = 1, CNT[i] do
|
||||||
|
depths[#depths + 1] = depth
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local litTable, litBits = huffman(depths)
|
||||||
|
loop(output, stream, litTable, litBits, STATIC_HUFFMAN, STATIC_BITS)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function uncompressed(output, stream)
|
||||||
|
flushBits(stream, band(stream.count, 7))
|
||||||
|
local length = getBits(stream, 16); getBits(stream, 16)
|
||||||
|
local buffer, position = stream.buffer, stream.position
|
||||||
|
for i = position, position + length - 1 do
|
||||||
|
output[#output + 1] = byte(buffer, i, i)
|
||||||
|
end
|
||||||
|
stream.position = position + length
|
||||||
|
end
|
||||||
|
|
||||||
|
local function int2le(buffer, position)
|
||||||
|
local a, b = byte(buffer, position, position + 1)
|
||||||
|
return b * 256 + a
|
||||||
|
end
|
||||||
|
|
||||||
|
local function int4le(buffer, position)
|
||||||
|
local a, b, c, d = byte(buffer, position, position + 3)
|
||||||
|
return ((d * 256 + c) * 256 + b) * 256 + a
|
||||||
|
end
|
||||||
|
|
||||||
|
local inflate = {}
|
||||||
|
|
||||||
|
---@class BitStream
|
||||||
|
---@field buffer string Character Buffer
|
||||||
|
---@field position integer Position in the character buffer
|
||||||
|
---@field bits integer Bits buffer
|
||||||
|
---@field count integer Number of bits in the buffer
|
||||||
|
local BitStream = {}
|
||||||
|
BitStream.__index = BitStream
|
||||||
|
|
||||||
|
---Creates a new bitstream object with the specified buffer
|
||||||
|
---@param buffer string The character buffer to use for the bitstream.
|
||||||
|
---@return BitStream
|
||||||
|
function inflate.new(buffer)
|
||||||
|
local EOCD
|
||||||
|
local pos = 1
|
||||||
|
repeat
|
||||||
|
local i, j = find(buffer, 'PK\5\6', pos, true)
|
||||||
|
if i then
|
||||||
|
EOCD = i
|
||||||
|
pos = j + 1
|
||||||
|
end
|
||||||
|
until not i
|
||||||
|
if EOCD then
|
||||||
|
buffer = sub(buffer, 1, EOCD + 19)..'\0\0'
|
||||||
|
end
|
||||||
|
local object = {buffer = buffer, position = 0, bits = 0, count = 0}
|
||||||
|
return setmetatable(object, BitStream)
|
||||||
|
end
|
||||||
|
---Update the chunk size to be used in inflation. Defaults to `4096`
|
||||||
|
---@param size integer The new chunk size, should be a power of 2.
|
||||||
|
function inflate.setChunkSize(size)
|
||||||
|
CHUNK_SIZE = size
|
||||||
|
end
|
||||||
|
|
||||||
|
---Returns an iterator that spans the list of files in the stream
|
||||||
|
---@return fun(): name: string?, offset: integer?, size: integer?, packed: boolean?, crc: integer?
|
||||||
|
function BitStream:files()
|
||||||
|
local buffer = self.buffer
|
||||||
|
local position = int4le(buffer, #buffer - 5) + 1
|
||||||
|
return function()
|
||||||
|
if int4le(buffer, position) ~= 33639248 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local packed = int2le(buffer, position + 10) ~= 0
|
||||||
|
local crc = int4le(buffer, position + 16)
|
||||||
|
local length = int2le(buffer, position + 28)
|
||||||
|
local offset = int4le(buffer, position + 42) + 1
|
||||||
|
local name = sub(buffer, position + 46, position + 45 + length)
|
||||||
|
position = position + 46 + length + int2le(buffer, position + 30) + int2le(buffer, position + 32)
|
||||||
|
return name, offset + 30 + length + int2le(buffer, offset + 28), int4le(buffer, offset + 18), packed, crc
|
||||||
|
end
|
||||||
|
end
|
||||||
|
---Inflates the bitstream starting from the specified offset and optionally performs checksum verification
|
||||||
|
---@param offset integer The position at which to begin inflating.
|
||||||
|
---@param crc? integer The checksum value to use for verification.
|
||||||
|
---@return string
|
||||||
|
function BitStream:inflate(offset, crc)
|
||||||
|
local output, buffer = {}, {}
|
||||||
|
local last, typ
|
||||||
|
self.bits = 0
|
||||||
|
self.count = 0
|
||||||
|
self.position = offset
|
||||||
|
repeat
|
||||||
|
last, typ = getBits(self, 1), getBits(self, 2)
|
||||||
|
typ = typ == 0 and uncompressed(output, self) or typ == 1 and static(output, self) or typ == 2 and dynamic(output, self)
|
||||||
|
until last == 1
|
||||||
|
local size = #output
|
||||||
|
for i = 1, size, CHUNK_SIZE do
|
||||||
|
buffer[#buffer + 1] = char(unpack(output, i, min(i + CHUNK_SIZE - 1, size)))
|
||||||
|
end
|
||||||
|
local result = concat(buffer)
|
||||||
|
if crc and crc ~= generateCrc32(result) then
|
||||||
|
error("Checksum verification failed: the computed CRC does not match the expected value.", 2)
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
---Extracts a specific file from the bitstream
|
||||||
|
---@param filepath string The file to unzip.
|
||||||
|
---@param verify? boolean Whether or not to perform checksum verification.
|
||||||
|
---@return string
|
||||||
|
function BitStream:unzip(filepath, verify)
|
||||||
|
for name, offset, size, packed, crc in self:files() do
|
||||||
|
if name == filepath then
|
||||||
|
return packed and self:inflate(offset, verify and crc or nil) or sub(self.buffer, offset, offset + size - 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
error('File "'..filepath..'" not found in ZIP archive.')
|
||||||
|
end
|
||||||
|
--- Extracts unpacked contents from the bitstream at the specified offset and size
|
||||||
|
--- @param offset integer The starting position from which to extract the contents.
|
||||||
|
--- @param size integer The size of the contents to extract.
|
||||||
|
function BitStream:extract(offset, size)
|
||||||
|
return sub(self.buffer, offset, offset + size - 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
return inflate
|
||||||
@@ -5,84 +5,267 @@ local unpack = unpack or table.unpack
|
|||||||
input_loaded, input = pcall(require, "mp.input")
|
input_loaded, input = pcall(require, "mp.input")
|
||||||
uosc_available = false
|
uosc_available = false
|
||||||
latest_menu_anime = {}
|
latest_menu_anime = {}
|
||||||
|
local active_request_cancel = nil
|
||||||
|
local active_request_type = nil
|
||||||
|
local request_cancelled = false
|
||||||
|
|
||||||
|
-- 如果 latest_menu_anime 中存在首项为加载占位,移除它(兼容完整 menu props 或 items 数组)
|
||||||
|
local function strip_loading_from_latest_menu_anime()
|
||||||
|
if not latest_menu_anime or #latest_menu_anime == 0 then return end
|
||||||
|
local parsed = utils.parse_json(latest_menu_anime)
|
||||||
|
if not parsed or type(parsed) ~= "table" then return end
|
||||||
|
local function is_loading_item(it)
|
||||||
|
if not it or type(it) ~= "table" then return false end
|
||||||
|
if it.title == "加载数据中..." then return true end
|
||||||
|
if it.italic == true and it.icon == "spinner" then return true end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if parsed.items and type(parsed.items) == "table" then
|
||||||
|
if #parsed.items > 0 and is_loading_item(parsed.items[1]) then
|
||||||
|
table.remove(parsed.items, 1)
|
||||||
|
latest_menu_anime = utils.format_json(parsed)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if #parsed > 0 and is_loading_item(parsed[1]) then
|
||||||
|
table.remove(parsed, 1)
|
||||||
|
latest_menu_anime = utils.format_json(parsed)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 统一取消并发请求:设置取消标志、剥离加载占位并调用实际取消函数
|
||||||
|
local function perform_cancel_active_request(expected_type)
|
||||||
|
if expected_type and active_request_type and tostring(expected_type) ~= tostring(active_request_type) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if active_request_cancel then
|
||||||
|
request_cancelled = true
|
||||||
|
strip_loading_from_latest_menu_anime()
|
||||||
|
pcall(active_request_cancel)
|
||||||
|
active_request_cancel = nil
|
||||||
|
active_request_type = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function make_build_args(encoded_query)
|
||||||
|
return function(server)
|
||||||
|
local url = server .. "/api/v2/search/anime"
|
||||||
|
local full_url = url .. "?keyword=" .. encoded_query
|
||||||
|
return make_danmaku_request_args("GET", full_url)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function make_handle_response(ctx)
|
||||||
|
return function(server, err, out)
|
||||||
|
if request_cancelled then
|
||||||
|
ctx.remaining.n = math.max(0, ctx.remaining.n - 1)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local function do_final_update()
|
||||||
|
local final_items = {}
|
||||||
|
-- 按配置的 server 顺序拼接每个 server 的结果
|
||||||
|
for _, srv in ipairs(ctx.server_order or {}) do
|
||||||
|
local list = ctx.server_items and ctx.server_items[srv]
|
||||||
|
if list and type(list) == 'table' then
|
||||||
|
for _, v in ipairs(list) do table.insert(final_items, v) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if request_cancelled then return end
|
||||||
|
if uosc_available then
|
||||||
|
latest_menu_anime = update_menu_uosc(ctx.menu_type, ctx.menu_title, final_items, ctx.footnote, ctx.menu_cmd, ctx.query)
|
||||||
|
else
|
||||||
|
latest_menu_anime = utils.format_json(final_items)
|
||||||
|
if input_loaded then
|
||||||
|
input.terminate()
|
||||||
|
mp.add_timeout(0.1, function()
|
||||||
|
open_menu_select(final_items)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if err then
|
||||||
|
msg.debug(("search anime failed for %s: %s"):format(server, tostring(err)))
|
||||||
|
ctx.remaining.n = math.max(0, ctx.remaining.n - 1)
|
||||||
|
if ctx.remaining.n == 0 then pcall(do_final_update) end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local data = utils.parse_json(out)
|
||||||
|
if not data or not data.animes then
|
||||||
|
ctx.remaining.n = math.max(0, ctx.remaining.n - 1)
|
||||||
|
if ctx.remaining.n == 0 then pcall(do_final_update) end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
for _, anime in ipairs(data.animes) do
|
||||||
|
local key = anime.bangumiId or (anime.animeTitle and anime.animeTitle:gsub("%s+", " ") or nil)
|
||||||
|
if key and not ctx.seen[key] then
|
||||||
|
ctx.seen[key] = true
|
||||||
|
ctx.server_items[server] = ctx.server_items[server] or {}
|
||||||
|
local note = (ctx.server_notes and ctx.server_notes[server]) or nil
|
||||||
|
local hint = anime.typeDescription
|
||||||
|
if note and note ~= "" then
|
||||||
|
if hint and hint ~= "" then
|
||||||
|
hint = hint .. "|" .. note
|
||||||
|
else
|
||||||
|
hint = note
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(ctx.server_items[server], {
|
||||||
|
title = anime.animeTitle,
|
||||||
|
hint = hint,
|
||||||
|
value = { "script-message-to", mp.get_script_name(), "search-episodes-event", anime.animeTitle, anime.bangumiId, server },
|
||||||
|
})
|
||||||
|
ctx.total_count = (ctx.total_count or 0) + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local display_items = {}
|
||||||
|
if ctx.remaining.n > 0 then
|
||||||
|
local progress_msg = ctx.message or ""
|
||||||
|
if ctx.total_servers and ctx.total_servers > 1 and ctx.remaining and ctx.remaining.n then
|
||||||
|
local completed = math.max(0, (ctx.total_servers - ctx.remaining.n) + 1)
|
||||||
|
progress_msg = tostring(progress_msg):gsub("%.+$", "")
|
||||||
|
progress_msg = progress_msg .. string.format("(%d/%d)...", completed, ctx.total_servers)
|
||||||
|
end
|
||||||
|
table.insert(display_items, {
|
||||||
|
title = progress_msg,
|
||||||
|
value = "",
|
||||||
|
italic = true,
|
||||||
|
keep_open = true,
|
||||||
|
selectable = false,
|
||||||
|
icon = "spinner",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
-- 按 server_order 拼接当前已收到的结果
|
||||||
|
for _, srv in ipairs(ctx.server_order or {}) do
|
||||||
|
local list = ctx.server_items and ctx.server_items[srv]
|
||||||
|
if list and type(list) == 'table' then
|
||||||
|
for _, v in ipairs(list) do table.insert(display_items, v) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if uosc_available then
|
||||||
|
latest_menu_anime = update_menu_uosc(ctx.menu_type, ctx.menu_title, display_items, ctx.footnote, ctx.menu_cmd, ctx.query,
|
||||||
|
{ "script-message-to", mp.get_script_name(), "cancel-active-request", ctx.menu_type })
|
||||||
|
else
|
||||||
|
if not ctx.first_opened.val and input_loaded and (ctx.total_count or 0) > 0 then
|
||||||
|
ctx.first_opened.val = true
|
||||||
|
show_message("", 0)
|
||||||
|
input.terminate()
|
||||||
|
mp.add_timeout(0.1, function()
|
||||||
|
latest_menu_anime = utils.format_json(display_items)
|
||||||
|
open_menu_select(display_items)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
ctx.remaining.n = math.max(0, ctx.remaining.n - 1)
|
||||||
|
if ctx.remaining.n == 0 then
|
||||||
|
pcall(do_final_update)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- 打开番剧数据匹配菜单
|
-- 打开番剧数据匹配菜单
|
||||||
function get_animes(query)
|
function get_animes(query)
|
||||||
local encoded_query = url_encode(query)
|
local encoded_query = url_encode(query)
|
||||||
local url = options.api_server .. "/api/v2/search/anime"
|
local server_metas = get_api_server_list(options.api_server, true)
|
||||||
local params = "keyword=" .. encoded_query
|
local servers = {}
|
||||||
local full_url = url .. "?" .. params
|
local server_notes = {}
|
||||||
|
for _, m in ipairs(server_metas) do
|
||||||
|
table.insert(servers, m.url)
|
||||||
|
if m.note and m.note ~= '' then
|
||||||
|
server_notes[m.url] = m.note
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local items = {}
|
local items = {}
|
||||||
|
local seen = {}
|
||||||
|
local first_opened = false
|
||||||
|
local remaining = #servers
|
||||||
|
local total_servers = remaining
|
||||||
|
local server_items = {}
|
||||||
|
local server_order = servers
|
||||||
|
local total_count = 0
|
||||||
|
request_cancelled = false
|
||||||
|
|
||||||
local message = "加载数据中..."
|
local message = "加载数据中..."
|
||||||
local menu_type = "menu_anime"
|
local menu_type = "menu_anime"
|
||||||
local menu_title = "在此处输入番剧名称"
|
local menu_title = "在此处输入番剧名称"
|
||||||
local footnote = "使用enter或ctrl+enter进行搜索"
|
local footnote = "使用enter或ctrl+enter进行搜索"
|
||||||
local menu_cmd = { "script-message-to", mp.get_script_name(), "search-anime-event" }
|
local menu_cmd = { "script-message-to", mp.get_script_name(), "search-anime-event" }
|
||||||
|
|
||||||
|
local function strip_trailing_dots(s)
|
||||||
|
if not s then return "" end
|
||||||
|
return tostring(s):gsub("%.+$", "")
|
||||||
|
end
|
||||||
|
|
||||||
|
local initial_message = message
|
||||||
|
if total_servers and total_servers > 1 then
|
||||||
|
initial_message = strip_trailing_dots(message) .. string.format("(%d/%d)...", 0, total_servers)
|
||||||
|
end
|
||||||
if uosc_available then
|
if uosc_available then
|
||||||
update_menu_uosc(menu_type, menu_title, message, footnote, menu_cmd, query)
|
active_request_type = menu_type
|
||||||
|
update_menu_uosc(menu_type, menu_title, initial_message, footnote, menu_cmd, query, "spinner",
|
||||||
|
{ "script-message-to", mp.get_script_name(), "cancel-active-request", menu_type })
|
||||||
else
|
else
|
||||||
show_message(message, 30)
|
show_message(initial_message, 30)
|
||||||
end
|
|
||||||
msg.verbose("尝试获取番剧数据:" .. full_url)
|
|
||||||
|
|
||||||
local args = make_danmaku_request_args("GET", full_url)
|
|
||||||
|
|
||||||
if args == nil then
|
|
||||||
return
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local res = mp.command_native({ name = 'subprocess', capture_stdout = true, capture_stderr = true, args = args })
|
msg.verbose("尝试获取番剧数据,servers: " .. table.concat(servers, ", ") .. " query: " .. query)
|
||||||
|
|
||||||
if not res.status or res.status ~= 0 then
|
local build_args = make_build_args(encoded_query)
|
||||||
local message = "获取数据失败"
|
|
||||||
if uosc_available then
|
-- 构造 ctx,用于 handle_response 闭包访问和修改共享状态
|
||||||
update_menu_uosc(menu_type, menu_title, message, footnote, menu_cmd, query)
|
local ctx = {
|
||||||
else
|
items = items,
|
||||||
show_message(message, 3)
|
seen = seen,
|
||||||
|
first_opened = { val = first_opened },
|
||||||
|
remaining = { n = remaining },
|
||||||
|
message = message,
|
||||||
|
total_servers = total_servers,
|
||||||
|
menu_type = menu_type,
|
||||||
|
menu_title = menu_title,
|
||||||
|
footnote = footnote,
|
||||||
|
menu_cmd = menu_cmd,
|
||||||
|
query = query,
|
||||||
|
server_items = server_items,
|
||||||
|
server_order = server_order,
|
||||||
|
server_notes = server_notes,
|
||||||
|
total_count = total_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
local handle_response = make_handle_response(ctx)
|
||||||
|
|
||||||
|
local cancel_fn = parallel_requests(servers, build_args, handle_response, function()
|
||||||
|
if request_cancelled then return end
|
||||||
|
local final_items = {}
|
||||||
|
for _, srv in ipairs(ctx.server_order or {}) do
|
||||||
|
local list = ctx.server_items and ctx.server_items[srv]
|
||||||
|
if list and type(list) == 'table' then
|
||||||
|
for _, v in ipairs(list) do table.insert(final_items, v) end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
msg.error("HTTP 请求失败:" .. res.stderr)
|
|
||||||
end
|
|
||||||
|
|
||||||
local response = utils.parse_json(res.stdout)
|
|
||||||
|
|
||||||
if not response or not response.animes then
|
|
||||||
local message = "无结果"
|
|
||||||
if uosc_available then
|
if uosc_available then
|
||||||
update_menu_uosc(menu_type, menu_title, message, footnote, menu_cmd, query)
|
latest_menu_anime = update_menu_uosc(ctx.menu_type, ctx.menu_title, final_items, ctx.footnote, ctx.menu_cmd, ctx.query)
|
||||||
else
|
else
|
||||||
show_message(message, 3)
|
latest_menu_anime = utils.format_json(final_items)
|
||||||
|
if not ctx.first_opened.val and input_loaded and #final_items > 0 then
|
||||||
|
ctx.first_opened.val = true
|
||||||
|
show_message("", 0)
|
||||||
|
input.terminate()
|
||||||
|
mp.add_timeout(0.1, function()
|
||||||
|
open_menu_select(final_items)
|
||||||
|
end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
msg.info("无结果")
|
end, { concurrency = 5, per_request_timeout = 30 })
|
||||||
return
|
active_request_cancel = cancel_fn
|
||||||
end
|
active_request_type = menu_type
|
||||||
|
|
||||||
for _, anime in ipairs(response.animes) do
|
|
||||||
table.insert(items, {
|
|
||||||
title = anime.animeTitle,
|
|
||||||
hint = anime.typeDescription,
|
|
||||||
value = {
|
|
||||||
"script-message-to",
|
|
||||||
mp.get_script_name(),
|
|
||||||
"search-episodes-event",
|
|
||||||
anime.animeTitle, anime.bangumiId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
if uosc_available then
|
|
||||||
latest_menu_anime = update_menu_uosc(menu_type, menu_title, items, footnote, menu_cmd, query)
|
|
||||||
elseif input_loaded then
|
|
||||||
show_message("", 0)
|
|
||||||
mp.add_timeout(0.1, function()
|
|
||||||
latest_menu_anime = utils.format_json(items)
|
|
||||||
open_menu_select(items)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function get_episodes(animeTitle, bangumiId)
|
function get_episodes(animeTitle, bangumiId, api_server)
|
||||||
local url = options.api_server .. "/api/v2/bangumi/" .. bangumiId
|
local url = api_server .. "/api/v2/bangumi/" .. bangumiId
|
||||||
local items = {}
|
local items = {}
|
||||||
|
|
||||||
local message = "加载数据中..."
|
local message = "加载数据中..."
|
||||||
@@ -91,7 +274,9 @@ function get_episodes(animeTitle, bangumiId)
|
|||||||
local footnote = "使用 / 打开筛选"
|
local footnote = "使用 / 打开筛选"
|
||||||
|
|
||||||
if uosc_available then
|
if uosc_available then
|
||||||
update_menu_uosc(menu_type, menu_title, message, footnote)
|
active_request_type = menu_type
|
||||||
|
update_menu_uosc(menu_type, menu_title, message, footnote, nil, nil, "spinner",
|
||||||
|
{ "script-message-to", mp.get_script_name(), "cancel-active-request", menu_type })
|
||||||
else
|
else
|
||||||
show_message(message, 30)
|
show_message(message, 30)
|
||||||
end
|
end
|
||||||
@@ -102,60 +287,70 @@ function get_episodes(animeTitle, bangumiId)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local res = mp.command_native({ name = 'subprocess', capture_stdout = true, capture_stderr = true, args = args })
|
request_cancelled = false
|
||||||
|
active_request_type = menu_type
|
||||||
if not res.status or res.status ~= 0 then
|
active_request_cancel = call_cmd_async(args, function(err, stdout)
|
||||||
local message = "获取数据失败"
|
active_request_cancel = nil
|
||||||
if uosc_available then
|
if request_cancelled then
|
||||||
update_menu_uosc(menu_type, menu_title, message, footnote)
|
return
|
||||||
else
|
|
||||||
show_message(message, 3)
|
|
||||||
end
|
end
|
||||||
msg.error("HTTP 请求失败:" .. res.stderr)
|
|
||||||
end
|
|
||||||
|
|
||||||
local response = utils.parse_json(res.stdout)
|
if err then
|
||||||
|
local message = "获取数据失败"
|
||||||
if not response or not response.bangumi or not response.bangumi.episodes then
|
if uosc_available then
|
||||||
local message = "无结果"
|
update_menu_uosc(menu_type, menu_title, message, footnote)
|
||||||
if uosc_available then
|
else
|
||||||
update_menu_uosc(menu_type, menu_title, message, footnote)
|
show_message(message, 3)
|
||||||
else
|
end
|
||||||
show_message(message, 3)
|
msg.error("HTTP 请求失败:" .. tostring(err))
|
||||||
|
return
|
||||||
end
|
end
|
||||||
msg.info("无结果")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
table.insert(items, {
|
local response = utils.parse_json(stdout)
|
||||||
title = "← 返回搜索结果",
|
if not response or not response.bangumi or not response.bangumi.episodes then
|
||||||
value = { "script-message-to", mp.get_script_name(), "open-latest-menu-anime", latest_menu_anime },
|
local message = "无结果"
|
||||||
keep_open = false,
|
if uosc_available then
|
||||||
selectable = true,
|
update_menu_uosc(menu_type, menu_title, message, footnote)
|
||||||
})
|
else
|
||||||
|
show_message(message, 3)
|
||||||
|
end
|
||||||
|
msg.info("无结果")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
for _, episode in ipairs(response.bangumi.episodes) do
|
|
||||||
table.insert(items, {
|
table.insert(items, {
|
||||||
title = episode.episodeTitle,
|
title = "← 返回搜索结果",
|
||||||
hint = episode.episodeNumber,
|
value = { "script-message-to", mp.get_script_name(), "open-latest-menu-anime", latest_menu_anime },
|
||||||
value = { "script-message-to", mp.get_script_name(), "load-danmaku",
|
|
||||||
animeTitle, episode.episodeTitle, episode.episodeId },
|
|
||||||
keep_open = false,
|
keep_open = false,
|
||||||
selectable = true,
|
selectable = true,
|
||||||
})
|
})
|
||||||
end
|
|
||||||
|
|
||||||
if uosc_available then
|
for _, episode in ipairs(response.bangumi.episodes) do
|
||||||
footnote = mp.get_property("filename")
|
table.insert(items, {
|
||||||
update_menu_uosc(menu_type, menu_title, items, footnote)
|
title = episode.episodeTitle,
|
||||||
elseif input_loaded then
|
hint = episode.episodeNumber,
|
||||||
mp.add_timeout(0.1, function()
|
value = { "script-message-to", mp.get_script_name(), "load-danmaku",
|
||||||
open_menu_select(items)
|
animeTitle, episode.episodeTitle, episode.episodeId, api_server },
|
||||||
end)
|
keep_open = false,
|
||||||
end
|
selectable = true,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
if uosc_available then
|
||||||
|
footnote = mp.get_property("filename")
|
||||||
|
update_menu_uosc(menu_type, menu_title, items, footnote)
|
||||||
|
elseif input_loaded then
|
||||||
|
show_message("", 0)
|
||||||
|
input.terminate()
|
||||||
|
mp.add_timeout(0.1, function()
|
||||||
|
open_menu_select(items)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
active_request_type = menu_type
|
||||||
end
|
end
|
||||||
|
|
||||||
function update_menu_uosc(menu_type, menu_title, menu_item, menu_footnote, menu_cmd, query)
|
function update_menu_uosc(menu_type, menu_title, menu_item, menu_footnote, menu_cmd, query, message_icon, on_close)
|
||||||
local items = {}
|
local items = {}
|
||||||
if type(menu_item) == "string" then
|
if type(menu_item) == "string" then
|
||||||
table.insert(items, {
|
table.insert(items, {
|
||||||
@@ -165,7 +360,7 @@ function update_menu_uosc(menu_type, menu_title, menu_item, menu_footnote, menu_
|
|||||||
keep_open = true,
|
keep_open = true,
|
||||||
selectable = false,
|
selectable = false,
|
||||||
align = "center",
|
align = "center",
|
||||||
icon = "spinner",
|
icon = message_icon or "",
|
||||||
})
|
})
|
||||||
else
|
else
|
||||||
items = menu_item
|
items = menu_item
|
||||||
@@ -181,8 +376,19 @@ function update_menu_uosc(menu_type, menu_title, menu_item, menu_footnote, menu_
|
|||||||
search_suggestion = query,
|
search_suggestion = query,
|
||||||
items = items,
|
items = items,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if on_close ~= nil then
|
||||||
|
menu_props.on_close = on_close
|
||||||
|
end
|
||||||
|
|
||||||
|
local current_menu_type = mp.get_property_native('user-data/uosc/menu/type')
|
||||||
|
local cmd = "open-menu"
|
||||||
|
if current_menu_type and tostring(current_menu_type) == tostring(menu_type) then
|
||||||
|
cmd = "update-menu"
|
||||||
|
end
|
||||||
|
|
||||||
local json_props = utils.format_json(menu_props)
|
local json_props = utils.format_json(menu_props)
|
||||||
mp.commandv("script-message-to", "uosc", "open-menu", json_props)
|
mp.commandv("script-message-to", "uosc", cmd, json_props)
|
||||||
|
|
||||||
return json_props
|
return json_props
|
||||||
end
|
end
|
||||||
@@ -200,6 +406,7 @@ function open_menu_select(menu_items, is_time)
|
|||||||
items = item_titles,
|
items = item_titles,
|
||||||
submit = function(id)
|
submit = function(id)
|
||||||
input.terminate()
|
input.terminate()
|
||||||
|
perform_cancel_active_request()
|
||||||
local v = item_values[id]
|
local v = item_values[id]
|
||||||
if type(v) == 'table' then
|
if type(v) == 'table' then
|
||||||
mp.commandv(unpack(v))
|
mp.commandv(unpack(v))
|
||||||
@@ -207,6 +414,11 @@ function open_menu_select(menu_items, is_time)
|
|||||||
mp.command(v)
|
mp.command(v)
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
|
closed = function()
|
||||||
|
show_message("", 0)
|
||||||
|
input.terminate()
|
||||||
|
perform_cancel_active_request()
|
||||||
|
end,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -264,7 +476,9 @@ function open_input_menu()
|
|||||||
if uosc_available then
|
if uosc_available then
|
||||||
open_input_menu_uosc()
|
open_input_menu_uosc()
|
||||||
elseif input_loaded then
|
elseif input_loaded then
|
||||||
open_input_menu_get()
|
mp.add_timeout(0.01, function()
|
||||||
|
open_input_menu_get()
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -283,23 +497,29 @@ function open_add_menu_get()
|
|||||||
local serial = 0
|
local serial = 0
|
||||||
for url, source in pairs(DANMAKU.sources) do
|
for url, source in pairs(DANMAKU.sources) do
|
||||||
if source.data then
|
if source.data then
|
||||||
serial = serial + 1
|
|
||||||
local action, text
|
|
||||||
|
|
||||||
if source.from == "api_server" then
|
if source.from == "api_server" then
|
||||||
action = source.blocked and "unblock" or "block"
|
serial = serial + 1
|
||||||
text = string.format(" [%02d] %s [来源:弹幕服务器%s] ", serial, url,
|
local action = source.blocked and "unblock" or "block"
|
||||||
|
local text = string.format(" [%02d] %s [来源:弹幕服务器%s] ", serial, url,
|
||||||
source.blocked and "(已屏蔽)" or "(未屏蔽)")
|
source.blocked and "(已屏蔽)" or "(未屏蔽)")
|
||||||
|
local style = (tonumber(select_num) == serial) and "{\\c&HFFDE7F&\\b1}" or (action == "unblock" and "{\\c&H4C4CC3&\\b0}" or "{\\c&HCCCCCC&\\b0}")
|
||||||
|
deal_value[serial] = {value = url, action = action}
|
||||||
|
table.insert(menu_log, {text = text, style = style})
|
||||||
else
|
else
|
||||||
action = "delete"
|
serial = serial + 1
|
||||||
text = string.format(" [%02d] %s [来源:用户添加] ", serial, url)
|
local action1 = source.blocked and "unblock" or "block"
|
||||||
|
local text1 = string.format(" [%02d] %s [来源:用户添加]%s ", serial, url, source.blocked and " (已屏蔽)" or "(未屏蔽)")
|
||||||
|
local style1 = (tonumber(select_num) == serial) and "{\\c&HFFDE7F&\\b1}" or (action1 == "unblock" and "{\\c&H4C4CC3&\\b0}" or "{\\c&HCCCCCC&\\b0}")
|
||||||
|
deal_value[serial] = {value = url, action = action1}
|
||||||
|
table.insert(menu_log, {text = text1, style = style1})
|
||||||
|
|
||||||
|
serial = serial + 1
|
||||||
|
local action2 = "delete"
|
||||||
|
local text2 = string.format(" [%02d] %s [来源:用户添加] (删除) ", serial, url)
|
||||||
|
local style2 = (tonumber(select_num) == serial) and "{\\c&HFFDE7F&\\b1}" or "{\\c&HCCCCCC&\\b0}"
|
||||||
|
deal_value[serial] = {value = url, action = action2}
|
||||||
|
table.insert(menu_log, {text = text2, style = style2})
|
||||||
end
|
end
|
||||||
|
|
||||||
local style = (tonumber(select_num) == serial) and
|
|
||||||
"{\\c&HFFDE7F&\\b1}" or (action == "unblock" and "{\\c&H4C4CC3&\\b0}" or "{\\c&HCCCCCC&\\b0}")
|
|
||||||
|
|
||||||
deal_value[serial] = {value = url, action = action}
|
|
||||||
table.insert(menu_log, {text = text, style = style})
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -386,7 +606,7 @@ function open_add_menu_uosc()
|
|||||||
local sources = {}
|
local sources = {}
|
||||||
for url, source in pairs(DANMAKU.sources) do
|
for url, source in pairs(DANMAKU.sources) do
|
||||||
if source.data then
|
if source.data then
|
||||||
local item = {title = url, value = url, keep_open = true,}
|
local item = {title = utf8_sub(url, 1, 100), value = url, keep_open = true,}
|
||||||
if source.from == "api_server" then
|
if source.from == "api_server" then
|
||||||
if source.blocked then
|
if source.blocked then
|
||||||
item.hint = "来源:弹幕服务器(已屏蔽)"
|
item.hint = "来源:弹幕服务器(已屏蔽)"
|
||||||
@@ -397,7 +617,17 @@ function open_add_menu_uosc()
|
|||||||
end
|
end
|
||||||
else
|
else
|
||||||
item.hint = "来源:用户添加"
|
item.hint = "来源:用户添加"
|
||||||
item.actions = {{icon = "delete", name = "delete", label = "删除"},}
|
if source.blocked then
|
||||||
|
item.actions = {
|
||||||
|
{icon = "check", name = "unblock", label = "解除屏蔽"},
|
||||||
|
{icon = "delete", name = "delete", label = "删除"},
|
||||||
|
}
|
||||||
|
else
|
||||||
|
item.actions = {
|
||||||
|
{icon = "not_interested", name = "block", label = "屏蔽"},
|
||||||
|
{icon = "delete", name = "delete", label = "删除"},
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
table.insert(sources, item)
|
table.insert(sources, item)
|
||||||
end
|
end
|
||||||
@@ -421,7 +651,9 @@ function open_add_menu()
|
|||||||
if uosc_available then
|
if uosc_available then
|
||||||
open_add_menu_uosc()
|
open_add_menu_uosc()
|
||||||
elseif input_loaded then
|
elseif input_loaded then
|
||||||
open_add_menu_get()
|
mp.add_timeout(0.01, function()
|
||||||
|
open_add_menu_get()
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -915,7 +1147,7 @@ function open_delay_menu_uosc(source_url, status)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local item = {title = url, value = url, keep_open = true,}
|
local item = {title = utf8_sub(url, 1, 100), value = url, keep_open = true,}
|
||||||
item.hint = "当前弹幕源延迟:" .. string.format("%.1f", delay + 1e-10) .. "秒"
|
item.hint = "当前弹幕源延迟:" .. string.format("%.1f", delay + 1e-10) .. "秒"
|
||||||
item.active = url == source_url
|
item.active = url == source_url
|
||||||
table.insert(sources, item)
|
table.insert(sources, item)
|
||||||
@@ -1013,7 +1245,10 @@ function open_add_total_menu_select()
|
|||||||
prompt = '选择:',
|
prompt = '选择:',
|
||||||
items = item_titles,
|
items = item_titles,
|
||||||
submit = function(id)
|
submit = function(id)
|
||||||
mp.commandv(unpack(item_values[id]))
|
local cmd = item_values[id]
|
||||||
|
mp.add_timeout(0.1, function()
|
||||||
|
mp.commandv(unpack(cmd))
|
||||||
|
end)
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
@@ -1026,6 +1261,10 @@ function open_add_total_menu()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function toggle_danmaku_switch(value)
|
||||||
|
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", value)
|
||||||
|
mp.set_property_bool(DANMAKU_SWITCH_ON, value == "on")
|
||||||
|
end
|
||||||
|
|
||||||
mp.commandv(
|
mp.commandv(
|
||||||
"script-message-to",
|
"script-message-to",
|
||||||
@@ -1114,11 +1353,12 @@ mp.register_script_message("set", function(prop, value)
|
|||||||
hide_danmaku_func()
|
hide_danmaku_func()
|
||||||
end
|
end
|
||||||
|
|
||||||
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", value)
|
toggle_danmaku_switch(value)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
-- 注册函数给 uosc 按钮使用
|
-- 注册函数给 uosc 按钮使用
|
||||||
mp.register_script_message("search-anime-event", function(query)
|
mp.register_script_message("search-anime-event", function(query)
|
||||||
|
perform_cancel_active_request()
|
||||||
if uosc_available then
|
if uosc_available then
|
||||||
mp.commandv("script-message-to", "uosc", "close-menu", "menu_danmaku")
|
mp.commandv("script-message-to", "uosc", "close-menu", "menu_danmaku")
|
||||||
end
|
end
|
||||||
@@ -1129,19 +1369,20 @@ mp.register_script_message("search-anime-event", function(query)
|
|||||||
get_animes(query)
|
get_animes(query)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
mp.register_script_message("search-episodes-event", function(animeTitle, bangumiId)
|
mp.register_script_message("search-episodes-event", function(animeTitle, bangumiId, api_server)
|
||||||
|
perform_cancel_active_request()
|
||||||
if uosc_available then
|
if uosc_available then
|
||||||
mp.commandv("script-message-to", "uosc", "close-menu", "menu_anime")
|
mp.commandv("script-message-to", "uosc", "close-menu", "menu_anime")
|
||||||
end
|
end
|
||||||
get_episodes(animeTitle, bangumiId)
|
|
||||||
|
get_episodes(animeTitle, bangumiId, api_server)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
-- Register script message to show the input menu
|
mp.register_script_message("load-danmaku", function(animeTitle, episodeTitle, episodeId, api_server)
|
||||||
mp.register_script_message("load-danmaku", function(animeTitle, episodeTitle, episodeId)
|
|
||||||
ENABLED = true
|
ENABLED = true
|
||||||
DANMAKU.anime = animeTitle
|
DANMAKU.anime = animeTitle
|
||||||
DANMAKU.episode = episodeTitle
|
DANMAKU.episode = episodeTitle
|
||||||
set_episode_id(episodeId, true)
|
set_episode_id(episodeId, true, api_server)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
mp.register_script_message("add-source-event", function(query)
|
mp.register_script_message("add-source-event", function(query)
|
||||||
@@ -1177,6 +1418,10 @@ mp.register_script_message("open-latest-menu-anime", function ()
|
|||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
mp.register_script_message('cancel-active-request', function(menu_type)
|
||||||
|
perform_cancel_active_request(menu_type)
|
||||||
|
end)
|
||||||
|
|
||||||
mp.register_script_message("setup-danmaku-style", function(query, text)
|
mp.register_script_message("setup-danmaku-style", function(query, text)
|
||||||
local event = utils.parse_json(query)
|
local event = utils.parse_json(query)
|
||||||
if event ~= nil then
|
if event ~= nil then
|
||||||
@@ -1273,7 +1518,7 @@ mp.register_script_message("setup-source-delay", function(query, text)
|
|||||||
end
|
end
|
||||||
local delay = parse_delay_input(text)
|
local delay = parse_delay_input(text)
|
||||||
if delay ~= nil then
|
if delay ~= nil then
|
||||||
mp.commandv("script-message", "danmaku-delay", tostring(delay), "0")
|
mp.commandv("script-message", "danmaku-delay", tostring(delay), "0", tostring(query))
|
||||||
mp.commandv("script-message-to", "uosc", "close-menu", "menu_delay")
|
mp.commandv("script-message-to", "uosc", "close-menu", "menu_delay")
|
||||||
mp.add_timeout(0.1, function()
|
mp.add_timeout(0.1, function()
|
||||||
open_delay_menu(query, "refresh")
|
open_delay_menu(query, "refresh")
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ local opt = require("mp.options")
|
|||||||
-- 选项
|
-- 选项
|
||||||
options = {
|
options = {
|
||||||
-- 指定弹幕服务器地址,自定义服务需兼容 dandanplay 的 api
|
-- 指定弹幕服务器地址,自定义服务需兼容 dandanplay 的 api
|
||||||
api_server = "https://api.dandanplay.net",
|
-- 可指定多个用逗号分隔的有序 api_server 列表
|
||||||
|
-- 支持每项使用 '|' 或 '#' 分隔备注,例如: "https://a.example.com|备用A" 或 "https://b.example.com#备用B"
|
||||||
|
api_server = "https://danmaku-api.152468.xyz",
|
||||||
-- 指定 b 站和爱腾优的弹幕获取的兜底服务器地址,主要用于获取非动画弹幕
|
-- 指定 b 站和爱腾优的弹幕获取的兜底服务器地址,主要用于获取非动画弹幕
|
||||||
-- 可用: https://api.danmu.icu,https://dmku.hls.one
|
-- 可用: https://api.danmu.icu,https://dmku.hls.one
|
||||||
fallback_server = "https://api.danmu.icu",
|
fallback_server = "https://api.danmu.icu",
|
||||||
@@ -15,6 +17,10 @@ options = {
|
|||||||
autoload_local_danmaku = false,
|
autoload_local_danmaku = false,
|
||||||
autoload_for_url = false,
|
autoload_for_url = false,
|
||||||
save_danmaku = false,
|
save_danmaku = false,
|
||||||
|
-- 指定弹幕保存目录。为空时保存到视频同目录;目录需要用户提前创建
|
||||||
|
save_danmaku_path = "",
|
||||||
|
-- 指定 save_danmaku_path 的应用范围:local / url / all
|
||||||
|
save_danmaku_path_mode = "local",
|
||||||
user_agent = "mpv_danmaku/1.0",
|
user_agent = "mpv_danmaku/1.0",
|
||||||
proxy = "",
|
proxy = "",
|
||||||
-- 可选:向 HTTP 请求传递 cookie.txt 文件路径
|
-- 可选:向 HTTP 请求传递 cookie.txt 文件路径
|
||||||
@@ -25,6 +31,8 @@ options = {
|
|||||||
fps = "60/1.001",
|
fps = "60/1.001",
|
||||||
-- 指定合并重复弹幕的时间间隔的容差值,单位为秒。默认值: -1,表示禁用
|
-- 指定合并重复弹幕的时间间隔的容差值,单位为秒。默认值: -1,表示禁用
|
||||||
merge_tolerance = -1,
|
merge_tolerance = -1,
|
||||||
|
-- 合并重复弹幕时是否强制合并类型和颜色不同的弹幕。默认值: false,表示仅合并类型和颜色相同的弹幕
|
||||||
|
merge_without_style = false,
|
||||||
-- 指定弹幕关联历史记录文件的路径,支持绝对路径和相对路径
|
-- 指定弹幕关联历史记录文件的路径,支持绝对路径和相对路径
|
||||||
history_path = "~~/danmaku-history.json",
|
history_path = "~~/danmaku-history.json",
|
||||||
open_search_danmaku_menu_key = "Ctrl+d",
|
open_search_danmaku_menu_key = "Ctrl+d",
|
||||||
|
|||||||
@@ -10,6 +10,33 @@ local function ass_escape(text)
|
|||||||
:gsub("\n", "\\N")
|
:gsub("\n", "\\N")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- 构建 per-source 的延迟查询函数
|
||||||
|
local function make_delay_lookup(source)
|
||||||
|
local segments = nil
|
||||||
|
local prefix = nil
|
||||||
|
if source and source.delay_segments and #source.delay_segments > 0 then
|
||||||
|
segments = {}
|
||||||
|
for i, v in ipairs(source.delay_segments) do segments[i] = v end
|
||||||
|
table.sort(segments, function(a, b) return (a.start or 0) < (b.start or 0) end)
|
||||||
|
prefix = {}
|
||||||
|
local s = 0
|
||||||
|
for i, v in ipairs(segments) do
|
||||||
|
s = s + (v.delay or 0)
|
||||||
|
prefix[i] = s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return function(t)
|
||||||
|
local segs = segments or {}
|
||||||
|
local pre = prefix or {}
|
||||||
|
if #segs == 0 then return 0 end
|
||||||
|
local idx = binary_search(segs, t, function(s) return (s and s.start) or 0 end)
|
||||||
|
local target = idx - 1
|
||||||
|
if target < 1 then return 0 end
|
||||||
|
return pre[target] or 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local function xml_unescape(str)
|
local function xml_unescape(str)
|
||||||
return str:gsub(""", "\"")
|
return str:gsub(""", "\"")
|
||||||
:gsub("'", "'")
|
:gsub("'", "'")
|
||||||
@@ -154,62 +181,103 @@ local function merge_duplicate_danmaku(danmakus, threshold)
|
|||||||
local groups = {}
|
local groups = {}
|
||||||
|
|
||||||
for _, d in ipairs(danmakus) do
|
for _, d in ipairs(danmakus) do
|
||||||
local tkey = tostring(d.type or "")
|
local tkey = options.merge_without_style and "any" or tostring(d.type or "")
|
||||||
local ckey = tostring(d.color or "")
|
|
||||||
local text = d.text or ""
|
local text = d.text or ""
|
||||||
|
|
||||||
groups[tkey] = groups[tkey] or {}
|
groups[text] = groups[text] or {}
|
||||||
groups[tkey][ckey] = groups[tkey][ckey] or {}
|
groups[text][tkey] = groups[text][tkey] or {}
|
||||||
groups[tkey][ckey][text] = groups[tkey][ckey][text] or {}
|
table.insert(groups[text][tkey], d)
|
||||||
table.insert(groups[tkey][ckey][text], d)
|
end
|
||||||
|
|
||||||
|
local final_groups = {}
|
||||||
|
|
||||||
|
-- 颜色合并
|
||||||
|
for _, type_groups in pairs(groups) do
|
||||||
|
for _, list in pairs(type_groups) do
|
||||||
|
if options.merge_without_style then
|
||||||
|
table.insert(final_groups, list)
|
||||||
|
else
|
||||||
|
local color_groups = {}
|
||||||
|
-- 利用弹幕同色大量重复的特征,缓存 颜色值 -> color_groups 索引
|
||||||
|
local exact_color_cache = {}
|
||||||
|
|
||||||
|
for _, d in ipairs(list) do
|
||||||
|
local c = d.color or 16777215
|
||||||
|
local g_idx = exact_color_cache[c]
|
||||||
|
|
||||||
|
if g_idx then
|
||||||
|
-- 命中缓存,直接插入
|
||||||
|
table.insert(color_groups[g_idx], d)
|
||||||
|
else
|
||||||
|
-- 未命中的颜色值,计算它是否落入已有代表颜色的 15 容差范围内
|
||||||
|
local found = false
|
||||||
|
for i, cg in ipairs(color_groups) do
|
||||||
|
if color_dist(cg[1].color or 16777215, c) <= 15 then
|
||||||
|
table.insert(cg, d)
|
||||||
|
-- 将该颜色值缓存为这个代表颜色的索引,后续遇到完全相同的颜色值可以直接命中
|
||||||
|
exact_color_cache[c] = i
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 超出容差范围的全新颜色代表,开辟新组
|
||||||
|
if not found then
|
||||||
|
table.insert(color_groups, {d})
|
||||||
|
exact_color_cache[c] = #color_groups
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, cg in ipairs(color_groups) do
|
||||||
|
table.insert(final_groups, cg)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local merged = {}
|
local merged = {}
|
||||||
local abs = math.abs
|
local abs = math.abs
|
||||||
|
|
||||||
for _, bytype in pairs(groups) do
|
for _, group in ipairs(final_groups) do
|
||||||
for _, bycolor in pairs(bytype) do
|
table.sort(group, function(a, b) return a.time < b.time end)
|
||||||
for _, group in pairs(bycolor) do
|
|
||||||
table.sort(group, function(a, b) return a.time < b.time end)
|
|
||||||
|
|
||||||
local i = 1
|
local i = 1
|
||||||
while i <= #group do
|
while i <= #group do
|
||||||
local base = group[i]
|
local base = group[i]
|
||||||
local times = { base.time }
|
local times = { base.time }
|
||||||
local count = 1
|
local count = 1
|
||||||
local j = i + 1
|
local j = i + 1
|
||||||
|
|
||||||
while j <= #group and abs(group[j].time - base.time) <= threshold do
|
while j <= #group and abs(group[j].time - base.time) <= threshold do
|
||||||
times[#times+1] = group[j].time
|
times[#times+1] = group[j].time
|
||||||
count = count + 1
|
count = count + 1
|
||||||
j = j + 1
|
j = j + 1
|
||||||
end
|
end
|
||||||
|
|
||||||
local same_time = true
|
local same_time = true
|
||||||
for k = 2, #times do
|
for k = 2, #times do
|
||||||
if times[k] ~= times[1] then
|
if times[k] ~= times[1] then
|
||||||
same_time = false
|
same_time = false
|
||||||
break
|
break
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local danmaku = {
|
|
||||||
time = base.time,
|
|
||||||
type = base.type,
|
|
||||||
size = base.size,
|
|
||||||
color = base.color,
|
|
||||||
text = base.text,
|
|
||||||
source = base.source,
|
|
||||||
orig_time = base.orig_time,
|
|
||||||
}
|
|
||||||
if count > 2 or not same_time then
|
|
||||||
danmaku.text = danmaku.text .. string.format("x%d", count)
|
|
||||||
end
|
|
||||||
|
|
||||||
table.insert(merged, danmaku)
|
|
||||||
i = j
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local danmaku = {
|
||||||
|
time = base.time,
|
||||||
|
type = base.type,
|
||||||
|
size = base.size,
|
||||||
|
color = base.color,
|
||||||
|
text = base.text,
|
||||||
|
source = base.source,
|
||||||
|
orig_time = base.orig_time,
|
||||||
|
}
|
||||||
|
if count > 2 or not same_time then
|
||||||
|
danmaku.text = danmaku.text .. string.format("x%d", count)
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(merged, danmaku)
|
||||||
|
i = j
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -259,8 +327,11 @@ local function limit_danmaku(danmakus, limit)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- 解析 XML 弹幕
|
-- 解析 XML 弹幕
|
||||||
local function parse_xml_danmaku(xml_string)
|
function parse_xml_danmaku(xml_string)
|
||||||
local danmakus = {}
|
local danmakus = {}
|
||||||
|
if not xml_string then
|
||||||
|
return danmakus
|
||||||
|
end
|
||||||
-- [^>]* 匹配其他 attributes
|
-- [^>]* 匹配其他 attributes
|
||||||
-- %f[^%s] 确保 p= 前面是空白字符
|
-- %f[^%s] 确保 p= 前面是空白字符
|
||||||
for p_attr, text in xml_string:gmatch('<d%s+[^>]*%f[^%s]p="([^"]+)"[^>]*>([^<]+)</d>') do
|
for p_attr, text in xml_string:gmatch('<d%s+[^>]*%f[^%s]p="([^"]+)"[^>]*>([^<]+)</d>') do
|
||||||
@@ -287,7 +358,7 @@ local function parse_xml_danmaku(xml_string)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- 解析 JSON 弹幕
|
-- 解析 JSON 弹幕
|
||||||
local function parse_json_danmaku(json_string)
|
function parse_json_danmaku(json_string)
|
||||||
local danmakus = {}
|
local danmakus = {}
|
||||||
if json_string:sub(1, 3) == "\239\187\191" then
|
if json_string:sub(1, 3) == "\239\187\191" then
|
||||||
json_string = json_string:sub(4)
|
json_string = json_string:sub(4)
|
||||||
@@ -469,10 +540,22 @@ end
|
|||||||
-- 将弹幕转换为 XML 格式
|
-- 将弹幕转换为 XML 格式
|
||||||
function convert_danmaku_to_xml(danmaku_out)
|
function convert_danmaku_to_xml(danmaku_out)
|
||||||
local danmakus = {}
|
local danmakus = {}
|
||||||
for _, source in pairs(DANMAKU.sources) do
|
for url, source in pairs(DANMAKU.sources) do
|
||||||
if not source.blocked and source.data then
|
if not source.blocked and source.data then
|
||||||
|
local get_cached_delay = make_delay_lookup(source)
|
||||||
|
|
||||||
for _, d in ipairs(source.data) do
|
for _, d in ipairs(source.data) do
|
||||||
table.insert(danmakus, d)
|
local base_time = d.orig_time or d.time
|
||||||
|
local adjusted_time = base_time + get_cached_delay(base_time)
|
||||||
|
table.insert(danmakus, {
|
||||||
|
orig_time = d.orig_time,
|
||||||
|
time = adjusted_time,
|
||||||
|
type = d.type,
|
||||||
|
size = d.size,
|
||||||
|
color = d.color,
|
||||||
|
text = d.text,
|
||||||
|
source = url,
|
||||||
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -521,29 +604,7 @@ function convert_danmaku_to_ass_events(force)
|
|||||||
local per_source_lists = {}
|
local per_source_lists = {}
|
||||||
for url, source in pairs(DANMAKU.sources) do
|
for url, source in pairs(DANMAKU.sources) do
|
||||||
if not source.blocked and source.data then
|
if not source.blocked and source.data then
|
||||||
local segments = nil
|
local get_cached_delay = make_delay_lookup(source)
|
||||||
local prefix = nil
|
|
||||||
if source.delay_segments and #source.delay_segments > 0 then
|
|
||||||
segments = {}
|
|
||||||
for i, v in ipairs(source.delay_segments) do segments[i] = v end
|
|
||||||
table.sort(segments, function(a, b) return (a.start or 0) < (b.start or 0) end)
|
|
||||||
prefix = {}
|
|
||||||
local s = 0
|
|
||||||
for i, v in ipairs(segments) do
|
|
||||||
s = s + (v.delay or 0)
|
|
||||||
prefix[i] = s
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function get_cached_delay(t)
|
|
||||||
local segs = segments or {}
|
|
||||||
local pre = prefix or {}
|
|
||||||
if #segs == 0 then return 0 end
|
|
||||||
local idx = binary_search(segs, t, function(s) return (s and s.start) or 0 end)
|
|
||||||
local target = idx - 1
|
|
||||||
if target < 1 then return 0 end
|
|
||||||
return pre[target] or 0
|
|
||||||
end
|
|
||||||
|
|
||||||
local list = {}
|
local list = {}
|
||||||
for _, d in ipairs(source.data) do
|
for _, d in ipairs(source.data) do
|
||||||
@@ -711,6 +772,7 @@ function convert_danmaku_to_ass_events(force)
|
|||||||
clean_text = clean_text,
|
clean_text = clean_text,
|
||||||
pos = pos,
|
pos = pos,
|
||||||
move = move,
|
move = move,
|
||||||
|
layer = (style == "R2L") and 0 or 1,
|
||||||
source = d.source,
|
source = d.source,
|
||||||
}
|
}
|
||||||
table.insert(ass_events, event)
|
table.insert(ass_events, event)
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ local unpack = unpack or table.unpack
|
|||||||
|
|
||||||
local osd_width, osd_height, pause = 0, 0, true
|
local osd_width, osd_height, pause = 0, 0, true
|
||||||
local time_pos_observer_active = false
|
local time_pos_observer_active = false
|
||||||
local overlay = mp.create_osd_overlay('ass-events')
|
local overlay_low = mp.create_osd_overlay('ass-events')
|
||||||
|
local overlay_high = mp.create_osd_overlay('ass-events')
|
||||||
|
|
||||||
local function realtime_position_text(event, pos, displayarea)
|
local function realtime_position_text(event, pos, displayarea)
|
||||||
if not event.move then
|
if not event.move then
|
||||||
@@ -51,7 +52,8 @@ function render(pos_arg)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if not pos then
|
if not pos then
|
||||||
overlay:remove()
|
overlay_low:remove()
|
||||||
|
overlay_high:remove()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -67,7 +69,8 @@ function render(pos_arg)
|
|||||||
fontsize = options.fontsize - ratio * 2
|
fontsize = options.fontsize - ratio * 2
|
||||||
end
|
end
|
||||||
|
|
||||||
local ass_events = {}
|
local ass_events_low = {}
|
||||||
|
local ass_events_high = {}
|
||||||
local max_display = math.max(options.scrolltime, options.fixtime)
|
local max_display = math.max(options.scrolltime, options.fixtime)
|
||||||
local window_start = pos - max_display
|
local window_start = pos - max_display
|
||||||
|
|
||||||
@@ -99,22 +102,36 @@ function render(pos_arg)
|
|||||||
|
|
||||||
-- 构建 ASS 字符串
|
-- 构建 ASS 字符串
|
||||||
local ass_text = text and (ass_prefix .. text)
|
local ass_text = text and (ass_prefix .. text)
|
||||||
|
if ass_text then
|
||||||
table.insert(ass_events, ass_text)
|
if event.layer == nil or tonumber(event.layer) == 0 then
|
||||||
|
table.insert(ass_events_low, ass_text)
|
||||||
|
else
|
||||||
|
table.insert(ass_events_high, ass_text)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
overlay.res_x = width
|
-- 写入低层(滚动)和高层(顶/底)overlay,并设置 z 值以控制堆叠
|
||||||
overlay.res_y = height
|
overlay_low.res_x = width
|
||||||
overlay.data = table.concat(ass_events, '\n')
|
overlay_low.res_y = height
|
||||||
overlay:update()
|
overlay_low.z = 0
|
||||||
|
overlay_low.data = table.concat(ass_events_low, '\n')
|
||||||
|
overlay_low:update()
|
||||||
|
|
||||||
|
overlay_high.res_x = width
|
||||||
|
overlay_high.res_y = height
|
||||||
|
overlay_high.z = 1
|
||||||
|
overlay_high.data = table.concat(ass_events_high, '\n')
|
||||||
|
overlay_high:update()
|
||||||
end
|
end
|
||||||
|
|
||||||
local function time_pos_callback(_, time_pos)
|
local function time_pos_callback(_, time_pos)
|
||||||
if time_pos then
|
if time_pos then
|
||||||
render(time_pos)
|
render(time_pos)
|
||||||
else
|
else
|
||||||
overlay:remove()
|
overlay_low:remove()
|
||||||
|
overlay_high:remove()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -137,7 +154,7 @@ function render_danmaku(from_menu, no_osd)
|
|||||||
if not no_osd then
|
if not no_osd then
|
||||||
show_loaded(true)
|
show_loaded(true)
|
||||||
end
|
end
|
||||||
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", "on")
|
toggle_danmaku_switch("on")
|
||||||
show_danmaku_func()
|
show_danmaku_func()
|
||||||
else
|
else
|
||||||
show_message("")
|
show_message("")
|
||||||
@@ -179,7 +196,8 @@ function hide_danmaku_func()
|
|||||||
stop_time_observer()
|
stop_time_observer()
|
||||||
mp.set_property_bool(HAS_DANMAKU, false)
|
mp.set_property_bool(HAS_DANMAKU, false)
|
||||||
set_danmaku_visibility(false)
|
set_danmaku_visibility(false)
|
||||||
overlay:remove()
|
overlay_low:remove()
|
||||||
|
overlay_high:remove()
|
||||||
if filter_state("danmaku") then
|
if filter_state("danmaku") then
|
||||||
mp.commandv("vf", "remove", "@danmaku")
|
mp.commandv("vf", "remove", "@danmaku")
|
||||||
end
|
end
|
||||||
@@ -235,7 +253,8 @@ end)
|
|||||||
mp.add_hook("on_unload", 50, function()
|
mp.add_hook("on_unload", 50, function()
|
||||||
COMMENTS, DELAY = nil, 0
|
COMMENTS, DELAY = nil, 0
|
||||||
stop_time_observer()
|
stop_time_observer()
|
||||||
overlay:remove()
|
overlay_low:remove()
|
||||||
|
overlay_high:remove()
|
||||||
mp.set_property_native(DELAY_PROPERTY, 0)
|
mp.set_property_native(DELAY_PROPERTY, 0)
|
||||||
if filter_state("danmaku") then
|
if filter_state("danmaku") then
|
||||||
mp.commandv("vf", "remove", "@danmaku")
|
mp.commandv("vf", "remove", "@danmaku")
|
||||||
@@ -256,4 +275,5 @@ mp.add_hook("on_unload", 50, function()
|
|||||||
end
|
end
|
||||||
|
|
||||||
DANMAKU = {sources = {}, count = 1}
|
DANMAKU = {sources = {}, count = 1}
|
||||||
|
mp.set_property_native(DANMAKU_COUNT, 0)
|
||||||
end)
|
end)
|
||||||
@@ -201,6 +201,18 @@ function is_protocol(path)
|
|||||||
return type(path) == 'string' and (path:find('^%a[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= nil)
|
return type(path) == 'string' and (path:find('^%a[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function sanitize_filename(name)
|
||||||
|
if not name or name == "" then
|
||||||
|
return "danmaku"
|
||||||
|
end
|
||||||
|
name = name:gsub('[/\\:*?"<>|]', "_")
|
||||||
|
:gsub('^%s*(.-)%s*$', '%1')
|
||||||
|
if name == "" then
|
||||||
|
return "danmaku"
|
||||||
|
end
|
||||||
|
return name
|
||||||
|
end
|
||||||
|
|
||||||
function hex_to_bin(hexstr)
|
function hex_to_bin(hexstr)
|
||||||
return (hexstr:gsub('..', function (cc)
|
return (hexstr:gsub('..', function (cc)
|
||||||
return string.char(tonumber(cc, 16))
|
return string.char(tonumber(cc, 16))
|
||||||
@@ -214,6 +226,9 @@ end
|
|||||||
function hex_to_int_color(hex_color)
|
function hex_to_int_color(hex_color)
|
||||||
-- 移除颜色代码中的'#'字符
|
-- 移除颜色代码中的'#'字符
|
||||||
hex_color = hex_color:sub(2) -- 只保留颜色代码部分
|
hex_color = hex_color:sub(2) -- 只保留颜色代码部分
|
||||||
|
if not hex_color:match("^%x%x%x%x%x%x$") then
|
||||||
|
return 16777215
|
||||||
|
end
|
||||||
|
|
||||||
-- 提取R, G, B的十六进制值并转为整数
|
-- 提取R, G, B的十六进制值并转为整数
|
||||||
local r = tonumber(hex_color:sub(1, 2), 16)
|
local r = tonumber(hex_color:sub(1, 2), 16)
|
||||||
@@ -226,14 +241,14 @@ function hex_to_int_color(hex_color)
|
|||||||
return color_int
|
return color_int
|
||||||
end
|
end
|
||||||
|
|
||||||
local function get_type_from_position(position)
|
function color_dist(c1, c2)
|
||||||
if position == 0 then
|
local r1 = math.floor(c1 / 65536) % 256
|
||||||
return 1
|
local g1 = math.floor(c1 / 256) % 256
|
||||||
end
|
local b1 = c1 % 256
|
||||||
if position == 1 then
|
local r2 = math.floor(c2 / 65536) % 256
|
||||||
return 4
|
local g2 = math.floor(c2 / 256) % 256
|
||||||
end
|
local b2 = c2 % 256
|
||||||
return 5
|
return math.sqrt((r1-r2)^2 + (g1-g2)^2 + (b1-b2)^2)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- url编码转换
|
-- url编码转换
|
||||||
@@ -424,6 +439,42 @@ function contains_any(tab, val)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- 将一个逗号分隔的 api_server 字符串解析为有序列表
|
||||||
|
function get_api_server_list(api_server_str, meta)
|
||||||
|
local want_meta = meta or false
|
||||||
|
local metas = {}
|
||||||
|
if not api_server_str or api_server_str == "" then
|
||||||
|
if want_meta then return metas end
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
for part in string.gmatch(api_server_str, "[^,]+") do
|
||||||
|
local s = part:gsub('^%s*(.-)%s*$', '%1')
|
||||||
|
if s ~= '' then
|
||||||
|
local u, n = s:match('^(.-)[|#](.*)$')
|
||||||
|
local url = nil
|
||||||
|
local note = nil
|
||||||
|
if u then
|
||||||
|
url = u:gsub('^%s*(.-)%s*$', '%1')
|
||||||
|
note = n and n:gsub('^%s*(.-)%s*$', '%1') or nil
|
||||||
|
else
|
||||||
|
url = s:gsub('^%s*(.-)%s*$', '%1')
|
||||||
|
note = nil
|
||||||
|
end
|
||||||
|
table.insert(metas, { url = url, note = note })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #metas == 0 and api_server_str ~= '' then
|
||||||
|
table.insert(metas, { url = api_server_str, note = nil })
|
||||||
|
end
|
||||||
|
|
||||||
|
if want_meta then return metas end
|
||||||
|
local urls = {}
|
||||||
|
for _, m in ipairs(metas) do table.insert(urls, m.url) end
|
||||||
|
return urls
|
||||||
|
end
|
||||||
|
|
||||||
--读history 和 写history
|
--读history 和 写history
|
||||||
function read_file(file_path)
|
function read_file(file_path)
|
||||||
local file = io.open(file_path, "r") -- 打开文件,"r"表示只读模式
|
local file = io.open(file_path, "r") -- 打开文件,"r"表示只读模式
|
||||||
@@ -727,10 +778,29 @@ function number_to_chinese(num)
|
|||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- 内部的异步运行计数
|
||||||
|
local async_running_count = 0
|
||||||
|
|
||||||
|
function mark_async_start()
|
||||||
|
async_running_count = async_running_count + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
function mark_async_end()
|
||||||
|
if async_running_count > 0 then
|
||||||
|
async_running_count = async_running_count - 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function is_async_running()
|
||||||
|
return async_running_count > 0
|
||||||
|
end
|
||||||
|
|
||||||
-- 异步执行命令
|
-- 异步执行命令
|
||||||
-- 同时返回 abort 函数,用于取消异步命令
|
-- 同时返回 abort 函数,用于取消异步命令
|
||||||
function call_cmd_async(args, callback)
|
function call_cmd_async(args, callback)
|
||||||
async_running = true
|
-- 标记异步开始
|
||||||
|
mark_async_start()
|
||||||
|
|
||||||
local abort_signal = mp.command_native_async({
|
local abort_signal = mp.command_native_async({
|
||||||
name = 'subprocess',
|
name = 'subprocess',
|
||||||
capture_stderr = true,
|
capture_stderr = true,
|
||||||
@@ -738,9 +808,14 @@ function call_cmd_async(args, callback)
|
|||||||
playback_only = true,
|
playback_only = true,
|
||||||
args = args,
|
args = args,
|
||||||
}, function(success, result, error)
|
}, function(success, result, error)
|
||||||
|
-- 标记异步结束
|
||||||
|
mark_async_end()
|
||||||
|
|
||||||
if not success or not result or result.status ~= 0 then
|
if not success or not result or result.status ~= 0 then
|
||||||
local exit_code = (result and result.status or 'unknown')
|
local exit_code = (result and result.status or 'unknown')
|
||||||
local message = error or (result and result.stdout .. result.stderr) or ''
|
local message = error or (
|
||||||
|
result and ((result.stdout or '') .. (result.stderr or ''))
|
||||||
|
) or ''
|
||||||
callback('Calling failed. Exit code: ' .. exit_code .. ' Error: ' .. message, {})
|
callback('Calling failed. Exit code: ' .. exit_code .. ' Error: ' .. message, {})
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -753,3 +828,165 @@ function call_cmd_async(args, callback)
|
|||||||
mp.abort_async_command(abort_signal)
|
mp.abort_async_command(abort_signal)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function yield_once()
|
||||||
|
local co = coroutine.running()
|
||||||
|
mp.add_timeout(0, function()
|
||||||
|
coroutine.resume(co)
|
||||||
|
end)
|
||||||
|
coroutine.yield()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function make_safe_resume(co, timer_ref)
|
||||||
|
local resumed = false
|
||||||
|
|
||||||
|
return function(...)
|
||||||
|
if resumed then return end
|
||||||
|
resumed = true
|
||||||
|
|
||||||
|
if timer_ref.timer then
|
||||||
|
timer_ref.timer:kill()
|
||||||
|
timer_ref.timer = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, err = coroutine.resume(co, ...)
|
||||||
|
if not ok then
|
||||||
|
mp.msg.warn("resume failed: " .. tostring(err))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function await_call_cmd(args, timeout, on_start)
|
||||||
|
local co = coroutine.running()
|
||||||
|
|
||||||
|
local timer_ref = { timer = nil }
|
||||||
|
local safe_resume = make_safe_resume(co, timer_ref)
|
||||||
|
|
||||||
|
local abort_fn = call_cmd_async(args, function(err, out)
|
||||||
|
safe_resume(err, out)
|
||||||
|
end)
|
||||||
|
|
||||||
|
if on_start and type(on_start) == 'function' then
|
||||||
|
pcall(on_start, abort_fn)
|
||||||
|
end
|
||||||
|
|
||||||
|
if timeout and type(timeout) == 'number' and timeout > 0 then
|
||||||
|
timer_ref.timer = mp.add_timeout(timeout, function()
|
||||||
|
if abort_fn then pcall(abort_fn) end
|
||||||
|
safe_resume("timeout", nil)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return coroutine.yield()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 并行请求调度器
|
||||||
|
-- servers: { "url1", "url2", ... }
|
||||||
|
-- build_args_fn(server) -> args
|
||||||
|
-- per_response_cb(server, err, stdout)
|
||||||
|
-- final_cb() 可选
|
||||||
|
-- opts: { concurrency=3, per_request_timeout=10 }
|
||||||
|
function parallel_requests(servers, build_args_fn, per_response_cb, final_cb, opts)
|
||||||
|
if type(final_cb) == 'table' and opts == nil then
|
||||||
|
opts = final_cb
|
||||||
|
final_cb = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
opts = opts or {}
|
||||||
|
local concurrency = opts.concurrency or 3
|
||||||
|
local timeout = opts.per_request_timeout or 10
|
||||||
|
|
||||||
|
local in_flight_abort = {}
|
||||||
|
local in_flight_count = 0
|
||||||
|
local aborted = false
|
||||||
|
local idx = 1
|
||||||
|
local monitor = nil
|
||||||
|
|
||||||
|
-- worker 协程
|
||||||
|
local function worker()
|
||||||
|
while true do
|
||||||
|
if aborted then return end
|
||||||
|
|
||||||
|
local i = idx
|
||||||
|
if i > #servers then return end
|
||||||
|
idx = idx + 1
|
||||||
|
|
||||||
|
local server = servers[i]
|
||||||
|
local args = build_args_fn(server)
|
||||||
|
|
||||||
|
if not args then
|
||||||
|
if not aborted then
|
||||||
|
pcall(per_response_cb, server, "no_args", nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
yield_once()
|
||||||
|
else
|
||||||
|
local ok, err, out = pcall(function()
|
||||||
|
return await_call_cmd(args, timeout, function(ab)
|
||||||
|
in_flight_abort[i] = ab
|
||||||
|
in_flight_count = in_flight_count + 1
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- 请求结束
|
||||||
|
if in_flight_abort[i] then
|
||||||
|
in_flight_abort[i] = nil
|
||||||
|
in_flight_count = in_flight_count - 1
|
||||||
|
end
|
||||||
|
|
||||||
|
if aborted then return end
|
||||||
|
|
||||||
|
if not ok then
|
||||||
|
pcall(per_response_cb, server, tostring(err), nil)
|
||||||
|
else
|
||||||
|
pcall(per_response_cb, server, err, out)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 启动 worker
|
||||||
|
local workers = {}
|
||||||
|
for i = 1, math.min(concurrency, #servers) do
|
||||||
|
local co = coroutine.create(worker)
|
||||||
|
local ok, res = coroutine.resume(co)
|
||||||
|
if not ok then
|
||||||
|
mp.msg.warn("worker start failed: " .. tostring(res))
|
||||||
|
end
|
||||||
|
workers[#workers + 1] = co
|
||||||
|
end
|
||||||
|
|
||||||
|
-- monitor(完成检测)
|
||||||
|
monitor = mp.add_periodic_timer(0.05, function()
|
||||||
|
if aborted then
|
||||||
|
monitor:kill()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local all_assigned = (idx > #servers)
|
||||||
|
local any_inflight = (in_flight_count > 0)
|
||||||
|
|
||||||
|
if all_assigned and not any_inflight then
|
||||||
|
monitor:kill()
|
||||||
|
if final_cb then pcall(final_cb) end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- 返回取消函数
|
||||||
|
return function()
|
||||||
|
if aborted then return end
|
||||||
|
aborted = true
|
||||||
|
|
||||||
|
for i, abort_fn in pairs(in_flight_abort) do
|
||||||
|
if abort_fn then pcall(abort_fn) end
|
||||||
|
in_flight_abort[i] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
in_flight_count = 0
|
||||||
|
|
||||||
|
if monitor then
|
||||||
|
monitor:kill()
|
||||||
|
monitor = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
local msg = require('mp.msg')
|
||||||
|
local utils = require("mp.utils")
|
||||||
|
|
||||||
|
local function resolve_bahamut_sn(input_string)
|
||||||
|
local start_index = 0
|
||||||
|
local end_index = 0
|
||||||
|
local count = 0
|
||||||
|
for i = 1, #input_string do
|
||||||
|
if input_string:sub(i, i) == ":" then
|
||||||
|
count = count + 1
|
||||||
|
if count == 2 then
|
||||||
|
start_index = i
|
||||||
|
elseif count == 3 then
|
||||||
|
end_index = i
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if start_index > 0 and end_index > 0 then
|
||||||
|
return input_string:sub(start_index + 1, end_index - 1)
|
||||||
|
else
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_type_from_position(position)
|
||||||
|
if position == 0 then
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
if position == 1 then
|
||||||
|
return 4
|
||||||
|
end
|
||||||
|
return 5
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 为 bahamut 网站的视频播放加载弹幕
|
||||||
|
function load_danmaku_for_bahamut(path, callback)
|
||||||
|
callback = callback or function() end
|
||||||
|
local path = path:gsub('%%(%x%x)', hex_to_char)
|
||||||
|
local sn = resolve_bahamut_sn(path)
|
||||||
|
if sn == nil then
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local url = "https://ani.gamer.com.tw/ajax/danmuGet.php"
|
||||||
|
local temp_file = "bahamut-" .. PID .. ".json"
|
||||||
|
local danmaku_json = utils.join_path(DANMAKU_PATH, temp_file)
|
||||||
|
local arg = {
|
||||||
|
"curl",
|
||||||
|
"-X",
|
||||||
|
"POST",
|
||||||
|
"-d",
|
||||||
|
"sn=" .. sn,
|
||||||
|
"-L",
|
||||||
|
"-s",
|
||||||
|
"--user-agent",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36",
|
||||||
|
"--header",
|
||||||
|
"Origin: https://ani.gamer.com.tw",
|
||||||
|
"--header",
|
||||||
|
"Content-Type: application/x-www-form-urlencoded;charset=utf-8",
|
||||||
|
"--header",
|
||||||
|
"Accept: application/json",
|
||||||
|
"--header",
|
||||||
|
"Authority: ani.gamer.com.tw",
|
||||||
|
"--output",
|
||||||
|
danmaku_json,
|
||||||
|
url,
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.proxy ~= "" then
|
||||||
|
table.insert(arg, '-x')
|
||||||
|
table.insert(arg, options.proxy)
|
||||||
|
end
|
||||||
|
|
||||||
|
if options.cookie_file and options.cookie_file ~= "" then
|
||||||
|
table.insert(arg, '-b')
|
||||||
|
table.insert(arg, mp.command_native({"expand-path", options.cookie_file}))
|
||||||
|
end
|
||||||
|
|
||||||
|
call_cmd_async(arg, function(error)
|
||||||
|
if error then
|
||||||
|
show_message("HTTP 请求失败,打开控制台查看详情", 5)
|
||||||
|
msg.error(error)
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not file_exists(danmaku_json) then
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local comments_json = read_file(danmaku_json)
|
||||||
|
os.remove(danmaku_json)
|
||||||
|
local comments = utils.parse_json(comments_json)
|
||||||
|
if not comments then
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local output_table = {}
|
||||||
|
for _, comment in ipairs(comments) do
|
||||||
|
local color = hex_to_int_color(comment["color"])
|
||||||
|
local mode = get_type_from_position(comment["position"])
|
||||||
|
local time = tonumber(comment["time"]) / 10
|
||||||
|
local c_param = string.format("%s,%s,%s,25,,,", time, color, mode)
|
||||||
|
table.insert(output_table, {
|
||||||
|
c = c_param,
|
||||||
|
m = comment["text"]
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local final_json_str = utils.format_json(output_table)
|
||||||
|
save_danmaku_json("https://ani.gamer.com.tw/animeVideo.php?sn=" .. sn, final_json_str)
|
||||||
|
load_danmaku(true)
|
||||||
|
callback(true)
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
local msg = require('mp.msg')
|
||||||
|
local utils = require("mp.utils")
|
||||||
|
|
||||||
|
local 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'
|
||||||
|
|
||||||
|
local function build_curl_args(url, extra_headers)
|
||||||
|
local args = {
|
||||||
|
'curl', '-L', '-s', '--compressed', '--user-agent', user_agent
|
||||||
|
}
|
||||||
|
extra_headers = extra_headers or {}
|
||||||
|
for _, h in ipairs(extra_headers) do
|
||||||
|
table.insert(args, '-H')
|
||||||
|
table.insert(args, h)
|
||||||
|
end
|
||||||
|
if options.cookie_file and options.cookie_file ~= '' then
|
||||||
|
table.insert(args, '-b')
|
||||||
|
table.insert(args, mp.command_native({'expand-path', options.cookie_file}))
|
||||||
|
end
|
||||||
|
if options.proxy and options.proxy ~= '' then
|
||||||
|
table.insert(args, '-x')
|
||||||
|
table.insert(args, options.proxy)
|
||||||
|
end
|
||||||
|
table.insert(args, url)
|
||||||
|
return args
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_cid()
|
||||||
|
local cid, danmaku_id = nil, nil
|
||||||
|
local tracks = mp.get_property_native("track-list")
|
||||||
|
for _, track in ipairs(tracks) do
|
||||||
|
if track["lang"] == "danmaku" then
|
||||||
|
cid = track["external-filename"]:match("/(%d-)%.xml$")
|
||||||
|
danmaku_id = track["id"]
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return cid, danmaku_id
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_bilibili_id_and_page(path)
|
||||||
|
local bvid, aid, page = nil, nil, nil
|
||||||
|
if not bvid then
|
||||||
|
bvid = path:match("/video/(BV[%w]+)")
|
||||||
|
or path:match("[?&]bvid=(BV[%w]+)")
|
||||||
|
end
|
||||||
|
if not aid then
|
||||||
|
aid = path:match("/video/av([%d]+)")
|
||||||
|
end
|
||||||
|
if not page then
|
||||||
|
page = tonumber(path:match("[?&]p=(%d+)"))
|
||||||
|
end
|
||||||
|
return bvid, aid, page or 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_bilibili_pagelist_args(bvid, aid)
|
||||||
|
local url
|
||||||
|
if bvid ~= nil then
|
||||||
|
url = "https://api.bilibili.com/x/player/pagelist?bvid=" .. bvid
|
||||||
|
else
|
||||||
|
url = "https://api.bilibili.com/x/player/pagelist?aid=" .. aid
|
||||||
|
end
|
||||||
|
local headers = {"Referer: https://www.bilibili.com/video/" .. (bvid or ("av" .. aid))}
|
||||||
|
return build_curl_args(url, headers)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_bilibili_cid(path, callback)
|
||||||
|
-- 扩展支持:普通视频(BV/av)、番剧(ep)、课程(cheese)
|
||||||
|
local api_bangumi_season = "https://api.bilibili.com/pgc/view/web/season"
|
||||||
|
local api_cheese_season = "https://api.bilibili.com/pugv/view/web/season"
|
||||||
|
|
||||||
|
local q = path
|
||||||
|
-- 解析普通投稿视频
|
||||||
|
local bvid, aid, p = get_bilibili_id_and_page(q)
|
||||||
|
if bvid or aid then
|
||||||
|
local args = get_bilibili_pagelist_args(bvid, aid)
|
||||||
|
call_cmd_async(args, function(error, json)
|
||||||
|
if error then
|
||||||
|
msg.warn("Failed to request bilibili pagelist: " .. tostring(error))
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local data = utils.parse_json(json)
|
||||||
|
local pages = data and data["data"]
|
||||||
|
if type(pages) ~= "table" then
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local page_info = pages[p] or pages[1]
|
||||||
|
local cid = page_info and page_info["cid"]
|
||||||
|
local part = page_info and page_info["part"]
|
||||||
|
callback(cid and tostring(cid) or nil, part and tostring(part) or nil)
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 番剧、番外等(含 ep)
|
||||||
|
if q:find("bangumi/") and q:find("ep") then
|
||||||
|
local epid = q:match("ep(%d+)") or q:match("ep(%d+)$")
|
||||||
|
if not epid then
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local url = api_bangumi_season .. "?ep_id=" .. epid
|
||||||
|
local arg = build_curl_args(url)
|
||||||
|
call_cmd_async(arg, function(error, json)
|
||||||
|
if error then
|
||||||
|
msg.warn("Failed to request bilibili bangumi info: " .. tostring(error))
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local data = utils.parse_json(json)
|
||||||
|
if not data or data.code ~= 0 or not data.result then
|
||||||
|
msg.warn("bilibili bangumi api returned error")
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- 查找正片
|
||||||
|
local episodes = data.result.episodes or {}
|
||||||
|
for _, ep in ipairs(episodes) do
|
||||||
|
if tostring(ep.id) == tostring(epid) then
|
||||||
|
callback(tostring(ep.cid), tostring(ep.share_copy or ep.title or ""))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- 查找 section(花絮等)
|
||||||
|
if type(data.result.section) == "table" then
|
||||||
|
for _, sec in ipairs(data.result.section) do
|
||||||
|
if sec.episodes then
|
||||||
|
for _, ep in ipairs(sec.episodes) do
|
||||||
|
if tostring(ep.id) == tostring(epid) then
|
||||||
|
callback(tostring(ep.cid), tostring(ep.share_copy or ep.title or ""))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
callback(nil)
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- cheese 课程
|
||||||
|
if q:find("cheese/") and q:find("ep") then
|
||||||
|
local epid = q:match("ep(%d+)") or q:match("ep(%d+)$")
|
||||||
|
if not epid then
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local url = api_cheese_season .. "?ep_id=" .. epid
|
||||||
|
local arg = build_curl_args(url)
|
||||||
|
call_cmd_async(arg, function(error, json)
|
||||||
|
if error then
|
||||||
|
msg.warn("Failed to request bilibili cheese info: " .. tostring(error))
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local data = utils.parse_json(json)
|
||||||
|
if not data or data.code ~= 0 or not data.data then
|
||||||
|
msg.warn("bilibili cheese api returned error")
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local episodes = data.data.episodes or {}
|
||||||
|
for _, ep in ipairs(episodes) do
|
||||||
|
if tostring(ep.id) == tostring(epid) then
|
||||||
|
callback(tostring(ep.cid), tostring(ep.title or ""))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
callback(nil)
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 其它情况返回 nil
|
||||||
|
callback(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function download_bilibili_danmaku(path, cid, from_menu, callback)
|
||||||
|
local url = "https://comment.bilibili.com/" .. cid .. ".xml"
|
||||||
|
local args = build_curl_args(url)
|
||||||
|
|
||||||
|
call_cmd_async(args, function(error, out)
|
||||||
|
if error then
|
||||||
|
show_message("HTTP request failed, see console for details", 5)
|
||||||
|
msg.error(error)
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not out or out == '' then
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
save_danmaku_xml(path, out)
|
||||||
|
load_danmaku(from_menu == nil and true or from_menu)
|
||||||
|
callback(true)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 为 bilibli 网站的视频播放加载弹幕
|
||||||
|
function load_danmaku_for_bilibili(path, callback)
|
||||||
|
callback = callback or function() end
|
||||||
|
local cid, danmaku_id = get_cid()
|
||||||
|
if danmaku_id ~= nil then
|
||||||
|
mp.commandv('sub-remove', danmaku_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
if cid == nil then
|
||||||
|
cid = mp.get_opt('cid')
|
||||||
|
if not cid then
|
||||||
|
local patterns = {
|
||||||
|
"bilivideo%.c[nom]+.*/resource/(%d+)%D+.*",
|
||||||
|
"bilivideo%.c[nom]+.*/(%d+)-%d+-%d+%..*%?",
|
||||||
|
}
|
||||||
|
local urls = {
|
||||||
|
path,
|
||||||
|
mp.get_property("stream-open-filename", ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pattern in ipairs(patterns) do
|
||||||
|
for _, url in ipairs(urls) do
|
||||||
|
if url:find(pattern) then
|
||||||
|
cid = url:match(pattern)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if cid == nil then
|
||||||
|
resolve_bilibili_cid(path, function(resolved_cid)
|
||||||
|
if resolved_cid then
|
||||||
|
download_bilibili_danmaku(path, resolved_cid, true, callback)
|
||||||
|
else
|
||||||
|
show_message("获取哔哩哔哩视频cid失败", 3)
|
||||||
|
msg.error("获取哔哩哔哩视频cid失败")
|
||||||
|
callback(false)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if cid ~= nil then
|
||||||
|
download_bilibili_danmaku(path, cid, true, callback)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
local msg = require('mp.msg')
|
||||||
|
local utils = require('mp.utils')
|
||||||
|
local inflate = require('modules/inflate')
|
||||||
|
|
||||||
|
local 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'
|
||||||
|
|
||||||
|
local function build_curl_args(url, extra_headers)
|
||||||
|
local args = {
|
||||||
|
'curl', '-L', '-s', '--compressed', '--user-agent', user_agent
|
||||||
|
}
|
||||||
|
extra_headers = extra_headers or {}
|
||||||
|
for _, h in ipairs(extra_headers) do
|
||||||
|
table.insert(args, '-H')
|
||||||
|
table.insert(args, h)
|
||||||
|
end
|
||||||
|
table.insert(args, url)
|
||||||
|
if options.cookie_file and options.cookie_file ~= '' then
|
||||||
|
table.insert(args, '-b')
|
||||||
|
table.insert(args, mp.command_native({'expand-path', options.cookie_file}))
|
||||||
|
end
|
||||||
|
return args
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_tvid_from_url(url, callback)
|
||||||
|
local id = url:match('v_(%w+)')
|
||||||
|
if not id then
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local api = string.format('https://pcw-api.iq.com/api/decode/%s?platformId=3&modeCode=intl&langCode=sg', id)
|
||||||
|
call_cmd_async(build_curl_args(api), function(err, out)
|
||||||
|
if err or not out or out == '' then
|
||||||
|
msg.warn('Iqiyi decode request failed: ' .. tostring(err))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local data = utils.parse_json(out)
|
||||||
|
if not data or not data['data'] then
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local raw = data['data']
|
||||||
|
local tvid_str
|
||||||
|
if type(raw) == 'number' then
|
||||||
|
tvid_str = string.format('%.0f', raw)
|
||||||
|
else
|
||||||
|
tvid_str = tostring(raw)
|
||||||
|
end
|
||||||
|
callback(tvid_str)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_video_info(tvid, callback)
|
||||||
|
local api = string.format('https://pcw-api.iqiyi.com/video/video/baseinfo/%s', tvid)
|
||||||
|
call_cmd_async(build_curl_args(api), function(err, out)
|
||||||
|
if err or not out or out == '' then
|
||||||
|
msg.warn('Iqiyi baseinfo request failed: ' .. tostring(err))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local data = utils.parse_json(out)
|
||||||
|
callback(data and data['data'] or nil)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function extract_tags(xml, tag)
|
||||||
|
local res = {}
|
||||||
|
if not xml then return res end
|
||||||
|
for v in xml:gmatch('<'..tag..'>(.-)</'..tag..'>') do
|
||||||
|
table.insert(res, v)
|
||||||
|
end
|
||||||
|
return res
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_xml_and_append(xml, contents, total_files)
|
||||||
|
local danmaku = extract_tags(xml, 'content')
|
||||||
|
local showTime = extract_tags(xml, 'showTime')
|
||||||
|
local color = extract_tags(xml, 'color')
|
||||||
|
|
||||||
|
if #danmaku == 0 then return end
|
||||||
|
local step = math.ceil(#danmaku * (total_files or 1) / 10000)
|
||||||
|
if step < 1 then step = 1 end
|
||||||
|
|
||||||
|
for i = 1, #danmaku, step do
|
||||||
|
local content = {}
|
||||||
|
local timepoint = tonumber(showTime[i]) or 0
|
||||||
|
local col = tonumber((color[i] or ''), 16) or 16777215
|
||||||
|
local txt = danmaku[i] or ''
|
||||||
|
content.c = string.format('%s,%s,%s,25,,,', timepoint, col, 1)
|
||||||
|
content.m = txt
|
||||||
|
table.insert(contents, content)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function try_decompress_file(path)
|
||||||
|
local f, err = io.open(path, 'rb')
|
||||||
|
if not f then return nil, err end
|
||||||
|
local data = f:read('*a')
|
||||||
|
f:close()
|
||||||
|
if not data or #data == 0 then return nil, 'empty' end
|
||||||
|
local b1,b2 = data:byte(1,2)
|
||||||
|
-- zip archive (PK..)
|
||||||
|
if b1 == 0x50 and b2 == 0x4b then
|
||||||
|
local bs = inflate.new(data)
|
||||||
|
if bs then
|
||||||
|
local it = bs:files()
|
||||||
|
local name = it()
|
||||||
|
if name then
|
||||||
|
local res = bs:unzip(name, true)
|
||||||
|
if res then return res end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- gzip
|
||||||
|
if b1 == 0x1f and b2 == 0x8b then
|
||||||
|
local function parse_gzip_start(d)
|
||||||
|
local flg = d:byte(4) or 0
|
||||||
|
local pos = 11
|
||||||
|
if (flg % 8) >= 4 then
|
||||||
|
local xlen = (d:byte(11) or 0) + ((d:byte(12) or 0) * 256)
|
||||||
|
pos = pos + 2 + xlen
|
||||||
|
end
|
||||||
|
if (flg % 16) >= 8 then
|
||||||
|
while d:byte(pos) and d:byte(pos) ~= 0 do pos = pos + 1 end
|
||||||
|
pos = pos + 1
|
||||||
|
end
|
||||||
|
if (flg % 32) >= 16 then
|
||||||
|
while d:byte(pos) and d:byte(pos) ~= 0 do pos = pos + 1 end
|
||||||
|
pos = pos + 1
|
||||||
|
end
|
||||||
|
if (flg % 4) >= 2 then
|
||||||
|
pos = pos + 2
|
||||||
|
end
|
||||||
|
return pos
|
||||||
|
end
|
||||||
|
local start = parse_gzip_start(data)
|
||||||
|
if start and start < #data - 8 then
|
||||||
|
local deflated = data:sub(start, #data - 8)
|
||||||
|
local bs = inflate.new(deflated)
|
||||||
|
if bs then
|
||||||
|
local res = bs:inflate(1)
|
||||||
|
if res then return res end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- zlib (check header checksum mod31)
|
||||||
|
if ((b1*256 + (b2 or 0)) % 31) == 0 and #data > 6 then
|
||||||
|
local deflated = data:sub(3, #data - 4)
|
||||||
|
local bs = inflate.new(deflated)
|
||||||
|
if bs then
|
||||||
|
local res = bs:inflate(1)
|
||||||
|
if res then return res end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- fallback: return raw data
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
|
||||||
|
local function save_output_and_load(output_table, source_url)
|
||||||
|
if #output_table == 0 then
|
||||||
|
show_message('未获取到任何弹幕', 3)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local final_json_str = utils.format_json(output_table)
|
||||||
|
save_danmaku_json(source_url, final_json_str)
|
||||||
|
load_danmaku(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 辅助函数:构造 curl 参数
|
||||||
|
local function build_args_for_server(server, referer)
|
||||||
|
local headers = {
|
||||||
|
'Referer: ' .. (referer or ''),
|
||||||
|
'Accept: */*',
|
||||||
|
}
|
||||||
|
local args = build_curl_args(server.url, headers)
|
||||||
|
local last = args[#args]
|
||||||
|
args[#args] = '--output'
|
||||||
|
table.insert(args, server.zfile)
|
||||||
|
table.insert(args, last)
|
||||||
|
return args
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 辅助函数:处理单个分段的响应并解压解析
|
||||||
|
local function handle_server_response(server, err, out, output_table, servers, referer)
|
||||||
|
if err then
|
||||||
|
msg.warn('请求弹幕段失败: ' .. tostring(server) .. ' 错误: ' .. tostring(err))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(out) == 'string' and out:find('<') then
|
||||||
|
parse_xml_and_append(out, output_table, #servers)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(server) == 'table' and server.zfile and utils.file_info(server.zfile) then
|
||||||
|
local content, derr = try_decompress_file(server.zfile)
|
||||||
|
if not content then
|
||||||
|
msg.warn('文件解压失败: ' .. tostring(server.url) .. ' 错误: ' .. tostring(derr))
|
||||||
|
os.remove(server.zfile)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not content or content == '' then
|
||||||
|
msg.warn('文件解压后为空: ' .. tostring(server.url))
|
||||||
|
os.remove(server.zfile)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(content) == 'string' and content:find('<') then
|
||||||
|
parse_xml_and_append(content, output_table, #servers)
|
||||||
|
else
|
||||||
|
msg.warn('无法解析弹幕段内容: ' .. tostring(server.url))
|
||||||
|
end
|
||||||
|
os.remove(server.zfile)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
msg.warn('无法处理弹幕段响应: ' .. tostring(server))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 辅助函数:处理已知 tvid 的主流程
|
||||||
|
local function process_iqiyi_with_tvid(tvid, url, callback)
|
||||||
|
get_video_info(tvid, function(videoInfo)
|
||||||
|
if not videoInfo then
|
||||||
|
show_message('获取爱奇艺视频信息失败', 3)
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local title = videoInfo['name'] or videoInfo['tvName'] or ''
|
||||||
|
local duration = tonumber(videoInfo['durationSec']) or 0
|
||||||
|
local albumid = videoInfo['albumId']
|
||||||
|
local categoryid = videoInfo['channelId'] or videoInfo['categoryId']
|
||||||
|
if title and title ~= '' then
|
||||||
|
DANMAKU.title = title
|
||||||
|
end
|
||||||
|
|
||||||
|
local page = math.ceil(duration / (60 * 5))
|
||||||
|
if page < 1 then page = 1 end
|
||||||
|
msg.verbose(string.format('tvid: %s duration: %s pages: %d', tvid, tostring(duration), page))
|
||||||
|
|
||||||
|
local servers = {}
|
||||||
|
for i = 0, page - 1 do
|
||||||
|
local part1 = tvid:sub(-4, -3) or ''
|
||||||
|
local part2 = tvid:sub(-2) or ''
|
||||||
|
local api_url = string.format('https://cmts.iqiyi.com/bullet/%s/%s/%s_300_%d.z', part1, part2, tvid, i + 1)
|
||||||
|
local qs = '?rn=0.0123456789123456&business=danmu&is_iqiyi=true&is_video_page=true&tvid='..url_encode(tvid)
|
||||||
|
if albumid then qs = qs .. '&albumid=' .. url_encode(tostring(albumid)) end
|
||||||
|
if categoryid then qs = qs .. '&categoryid=' .. url_encode(tostring(categoryid)) end
|
||||||
|
qs = qs .. '&qypid=01010021010000000000'
|
||||||
|
local full = api_url .. qs
|
||||||
|
local tmp_file = 'iqiyi_' .. PID .. '_' .. tostring(os.time()) .. '_' .. tostring(i) .. '.z'
|
||||||
|
local tmp_path = utils.join_path(DANMAKU_PATH, tmp_file)
|
||||||
|
table.insert(servers, { url = full, zfile = tmp_path, idx = i + 1 })
|
||||||
|
end
|
||||||
|
|
||||||
|
local output_table = {}
|
||||||
|
|
||||||
|
local function build_args_fn(server)
|
||||||
|
return build_args_for_server(server, url)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function per_response_cb(server, err, out)
|
||||||
|
return handle_server_response(server, err, out, output_table, servers, url)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function final_cb()
|
||||||
|
local ok = #output_table > 0
|
||||||
|
save_output_and_load(output_table, url)
|
||||||
|
callback(ok)
|
||||||
|
end
|
||||||
|
|
||||||
|
parallel_requests(servers, build_args_fn, per_response_cb, final_cb, {concurrency = 6, per_request_timeout = 15})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 为爱奇艺加载弹幕
|
||||||
|
function load_danmaku_for_iqiyi(path, callback)
|
||||||
|
callback = callback or function() end
|
||||||
|
local url = path or mp.get_property('stream-open-filename', '')
|
||||||
|
if not url or url == '' then
|
||||||
|
msg.error('无有效 URL')
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
get_tvid_from_url(url, function(tvid)
|
||||||
|
if not tvid then
|
||||||
|
show_message('获取爱奇艺 tvid 失败', 3)
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
process_iqiyi_with_tvid(tvid, url, callback)
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
local msg = require('mp.msg')
|
||||||
|
local utils = require('mp.utils')
|
||||||
|
|
||||||
|
local 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'
|
||||||
|
|
||||||
|
-- 解析多种 time 表示为秒数(支持数字、带小数、HH:MM:SS、MM:SS)
|
||||||
|
local function parse_mgtv_time(time_str)
|
||||||
|
if time_str == nil then return 0 end
|
||||||
|
if type(time_str) == 'number' then return time_str end
|
||||||
|
local s = tostring(time_str):gsub('^%s*(.-)%s*$', '%1')
|
||||||
|
local n = tonumber(s)
|
||||||
|
if n then return n end
|
||||||
|
local h, m, sec = s:match('^(%d+):(%d+):([%d%.]+)$')
|
||||||
|
if h and m and sec then
|
||||||
|
return tonumber(h) * 3600 + tonumber(m) * 60 + tonumber(sec)
|
||||||
|
end
|
||||||
|
local mm, ss = s:match('^(%d+):([%d%.]+)$')
|
||||||
|
if mm and ss then
|
||||||
|
return tonumber(mm) * 60 + tonumber(ss)
|
||||||
|
end
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 生成分段请求列表(每段以 ms 为单位的起点)
|
||||||
|
local function generate_mgtv_segments(api_base, total_seconds, step_ms)
|
||||||
|
local segments = {}
|
||||||
|
local end_time_ms = math.floor(total_seconds * 1000)
|
||||||
|
for i = 0, math.max(0, end_time_ms - 1), step_ms do
|
||||||
|
table.insert(segments, api_base .. tostring(i))
|
||||||
|
end
|
||||||
|
return segments
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 构建通用 curl 请求参数
|
||||||
|
local function build_mgtv_curl_args(target_url)
|
||||||
|
local args = {
|
||||||
|
'curl', '-s', '-L', '--compressed',
|
||||||
|
'--user-agent', user_agent,
|
||||||
|
'-H', 'Accept: application/json',
|
||||||
|
target_url,
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 解析单个分段返回并把弹幕追加到 output_table
|
||||||
|
local function parse_mgtv_segment(out, output_table)
|
||||||
|
if not out then return end
|
||||||
|
local j = utils.parse_json(out)
|
||||||
|
if not j or not j.data or not j.data.items then return end
|
||||||
|
for _, item in ipairs(j.data.items) do
|
||||||
|
local t_ms = tonumber(item.time) or 0
|
||||||
|
local time_s = t_ms / 1000
|
||||||
|
local content = item.content or ''
|
||||||
|
local mode = 1
|
||||||
|
local color = 16777215
|
||||||
|
local c_param = string.format('%.2f,%d,%d,25,,,', time_s, color, mode)
|
||||||
|
table.insert(output_table, { c = c_param, m = content })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function extract_mgtv_ids(path)
|
||||||
|
if not path then return nil, nil end
|
||||||
|
-- 常见格式: /b/<cid>/<vid>.html
|
||||||
|
local cid, vid = path:match('/b/(%d+)/([%w%._-]+)%.html')
|
||||||
|
if cid and vid then
|
||||||
|
vid = vid:match('([^.]+)') or vid
|
||||||
|
return cid, vid
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 回退:取最后两个 path segment
|
||||||
|
local segs = {}
|
||||||
|
for seg in path:gmatch('/([^/]+)') do table.insert(segs, seg) end
|
||||||
|
if #segs >= 2 then
|
||||||
|
cid = segs[#segs - 1]
|
||||||
|
vid = segs[#segs]
|
||||||
|
vid = vid and vid:match('([^.]+)') or nil
|
||||||
|
return cid, vid
|
||||||
|
end
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 为 芒果TV 加载弹幕
|
||||||
|
function load_danmaku_for_mgtv(path, callback)
|
||||||
|
callback = callback or function() end
|
||||||
|
local url = path or mp.get_property('stream-open-filename', '')
|
||||||
|
if not url or url == '' then
|
||||||
|
msg.error('mgtv: 无效的 url')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local cid, vid = extract_mgtv_ids(url)
|
||||||
|
if not cid or not vid then
|
||||||
|
msg.error('mgtv: 无法解析 cid/vid: ' .. tostring(url))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local info_api = 'https://pcweb.api.mgtv.com/video/info?cid=' .. cid .. '&vid=' .. vid
|
||||||
|
local api_danmaku_base = 'https://galaxy.bz.mgtv.com/rdbarrage?vid=' .. vid .. '&cid=' .. cid .. '&time='
|
||||||
|
|
||||||
|
local args = build_mgtv_curl_args(info_api)
|
||||||
|
|
||||||
|
call_cmd_async(args, function(err, out)
|
||||||
|
if err then
|
||||||
|
msg.error('mgtv: 请求 video/info 失败: ' .. tostring(err))
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local data = utils.parse_json(out)
|
||||||
|
if not data or data.code ~= 200 or not data.data or not data.data.info then
|
||||||
|
msg.info('mgtv: video/info 返回无效')
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local time_str = data.data.info.time
|
||||||
|
local total_seconds = parse_mgtv_time(time_str)
|
||||||
|
|
||||||
|
local step = 60 * 1000
|
||||||
|
local segments = generate_mgtv_segments(api_danmaku_base, total_seconds, step)
|
||||||
|
|
||||||
|
if #segments == 0 then
|
||||||
|
msg.info('mgtv: 未生成任何弹幕分段请求')
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local output_table = {}
|
||||||
|
|
||||||
|
local function per_response_cb(server, err, out)
|
||||||
|
if err then
|
||||||
|
msg.debug('mgtv segment request failed: ' .. tostring(server) .. ' err: ' .. tostring(err))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
parse_mgtv_segment(out, output_table)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function final_cb()
|
||||||
|
local ok = #output_table > 0
|
||||||
|
local final_json_str = utils.format_json(output_table)
|
||||||
|
save_danmaku_json(url, final_json_str)
|
||||||
|
load_danmaku(true)
|
||||||
|
callback(ok)
|
||||||
|
end
|
||||||
|
|
||||||
|
parallel_requests(segments, build_mgtv_curl_args, per_response_cb, final_cb, { concurrency = 6, per_request_timeout = 10 })
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
local msg = require('mp.msg')
|
||||||
|
local utils = require('mp.utils')
|
||||||
|
|
||||||
|
local 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'
|
||||||
|
|
||||||
|
-- 将 URL 中的百分号编码解码为字符
|
||||||
|
local function normalize_url(path)
|
||||||
|
if not path then return '' end
|
||||||
|
return (path:gsub('%%(%x%x)', hex_to_char))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 从 URL 提取 vid
|
||||||
|
local function extract_vid(url)
|
||||||
|
if not url then return nil end
|
||||||
|
local vid = url:match('[?&]vid=([^&?#]+)')
|
||||||
|
if not vid then
|
||||||
|
local last = nil
|
||||||
|
for seg in url:gmatch('/([^/?#]+)') do
|
||||||
|
last = seg
|
||||||
|
end
|
||||||
|
if last then
|
||||||
|
vid = last:match('([^%.]+)')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return vid
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 构造 curl 请求参数(通用)
|
||||||
|
local function build_curl_args(target_url)
|
||||||
|
local args = {
|
||||||
|
'curl',
|
||||||
|
'-L',
|
||||||
|
'-s',
|
||||||
|
'--compressed',
|
||||||
|
'--user-agent',
|
||||||
|
user_agent,
|
||||||
|
target_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.cookie_file and options.cookie_file ~= '' then
|
||||||
|
table.insert(args, '-b')
|
||||||
|
table.insert(args, mp.command_native({'expand-path', options.cookie_file}))
|
||||||
|
end
|
||||||
|
return args
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 解析单个 segment 返回并把弹幕追加到 output_table
|
||||||
|
local function parse_segment_to_output(seg_json, output_table)
|
||||||
|
if not seg_json or not seg_json['barrage_list'] then return end
|
||||||
|
for _, item in ipairs(seg_json['barrage_list']) do
|
||||||
|
local time = tonumber(item['time_offset']) and tonumber(item['time_offset']) / 1000 or 0
|
||||||
|
local color = 16777215
|
||||||
|
if item['content_style'] and item['content_style']['color'] then
|
||||||
|
local col = item['content_style']['color']
|
||||||
|
if type(col) == 'string' and col:match('^#') then
|
||||||
|
color = hex_to_int_color(col)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local mode = 1
|
||||||
|
local c_param = string.format('%s,%s,%s,25,,,', time, color, mode)
|
||||||
|
table.insert(output_table, {c = c_param, m = item['content'] or ''})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 保存并加载最终弹幕 JSON
|
||||||
|
local function save_output_and_load(output_table, source_url)
|
||||||
|
if #output_table == 0 then
|
||||||
|
show_message('未获取到任何弹幕', 3)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local final_json_str = utils.format_json(output_table)
|
||||||
|
save_danmaku_json(source_url, final_json_str)
|
||||||
|
load_danmaku(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 为 腾讯视频 加载弹幕
|
||||||
|
function load_danmaku_for_tencent(path, callback)
|
||||||
|
callback = callback or function() end
|
||||||
|
local url = normalize_url(path)
|
||||||
|
if not url or url == '' then
|
||||||
|
url = mp.get_property('stream-open-filename', '')
|
||||||
|
end
|
||||||
|
|
||||||
|
local vid = extract_vid(url)
|
||||||
|
if not vid then
|
||||||
|
msg.error('无法从 URL 中解析 vid: ' .. tostring(url))
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local api_base = 'https://dm.video.qq.com/barrage/base/' .. vid
|
||||||
|
local api_segment_base = 'https://dm.video.qq.com/barrage/segment/' .. vid .. '/'
|
||||||
|
|
||||||
|
local base_args = build_curl_args(api_base)
|
||||||
|
|
||||||
|
call_cmd_async(base_args, function(err, out)
|
||||||
|
if err then
|
||||||
|
msg.error('请求腾讯弹幕 base 失败: ' .. tostring(err))
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local base_json = utils.parse_json(out)
|
||||||
|
if not base_json or not base_json['segment_index'] then
|
||||||
|
show_message('好像没有弹幕哦', 3)
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 构造 segment 请求列表
|
||||||
|
local segments = {}
|
||||||
|
local seg_index = base_json['segment_index']
|
||||||
|
if type(seg_index) == 'table' then
|
||||||
|
for k, v in pairs(seg_index) do
|
||||||
|
local seg_name = nil
|
||||||
|
if type(v) == 'table' and v['segment_name'] then
|
||||||
|
seg_name = v['segment_name']
|
||||||
|
elseif type(k) == 'string' then
|
||||||
|
seg_name = k
|
||||||
|
end
|
||||||
|
if seg_name then
|
||||||
|
table.insert(segments, api_segment_base .. seg_name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #segments == 0 then
|
||||||
|
show_message('没有找到弹幕分段', 3)
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local output_table = {}
|
||||||
|
|
||||||
|
local function build_args_fn(server)
|
||||||
|
return build_curl_args(server)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function per_response_cb(server, err, out)
|
||||||
|
if err then
|
||||||
|
msg.warn('请求段失败: ' .. tostring(server) .. ' 错误: ' .. tostring(err))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local seg_json = utils.parse_json(out)
|
||||||
|
parse_segment_to_output(seg_json, output_table)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function final_cb()
|
||||||
|
local ok = #output_table > 0
|
||||||
|
save_output_and_load(output_table, url)
|
||||||
|
callback(ok)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 并行请求 segments
|
||||||
|
parallel_requests(segments, build_args_fn, per_response_cb, final_cb, {concurrency = 6, per_request_timeout = 15})
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
local msg = require('mp.msg')
|
||||||
|
local utils = require('mp.utils')
|
||||||
|
|
||||||
|
local 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'
|
||||||
|
|
||||||
|
local function build_curl_args(url, extra_headers)
|
||||||
|
local args = {'curl', '-L', '-s', '--compressed', '--user-agent', user_agent}
|
||||||
|
extra_headers = extra_headers or {}
|
||||||
|
for _, h in ipairs(extra_headers) do
|
||||||
|
table.insert(args, '-H')
|
||||||
|
table.insert(args, h)
|
||||||
|
end
|
||||||
|
if options.cookie_file and options.cookie_file ~= '' then
|
||||||
|
table.insert(args, '-b')
|
||||||
|
table.insert(args, mp.command_native({'expand-path', options.cookie_file}))
|
||||||
|
end
|
||||||
|
table.insert(args, url)
|
||||||
|
return args
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_set_cookie_headers(text)
|
||||||
|
local cookies = {}
|
||||||
|
if not text or text == '' then return cookies end
|
||||||
|
for line in text:gmatch('[^\r\n]+') do
|
||||||
|
local low = line:lower()
|
||||||
|
if low:find('set%-cookie:') then
|
||||||
|
local cookie_str = line:match(':%s*(.*)') or ''
|
||||||
|
for kv in cookie_str:gmatch('([^;]+)') do
|
||||||
|
local k, v = kv:match('^%s*(.-)%s*=%s*(.*)')
|
||||||
|
if k and v then
|
||||||
|
cookies[k] = v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return cookies
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_cna(callback)
|
||||||
|
local api = 'https://log.mmstat.com/eg.js'
|
||||||
|
local args = build_curl_args(api)
|
||||||
|
table.insert(args, #args, '-i')
|
||||||
|
call_cmd_async(args, function(err, out)
|
||||||
|
if err or not out or out == '' then
|
||||||
|
msg.warn('get_cna failed: ' .. tostring(err))
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local cookies = parse_set_cookie_headers(out)
|
||||||
|
callback(cookies['cna'])
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_tk_enc(callback)
|
||||||
|
local api_url = 'https://acs.youku.com/h5/mtop.com.youku.aplatform.weakget/1.0/?jsv=2.5.1&appKey=24679788'
|
||||||
|
local args = build_curl_args(api_url)
|
||||||
|
table.insert(args, #args, '-i')
|
||||||
|
call_cmd_async(args, function(err, out)
|
||||||
|
if err or not out or out == '' then
|
||||||
|
msg.warn('get_tk_enc failed: ' .. tostring(err))
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local cookies = parse_set_cookie_headers(out)
|
||||||
|
callback(cookies)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function yk_msg_sign(msg)
|
||||||
|
return MD5.sum(msg .. 'MkmC9SoIw6xCkSKHhJ7b5D2r51kBiREr')
|
||||||
|
end
|
||||||
|
|
||||||
|
local function yk_t_sign(token, t, appkey, data)
|
||||||
|
local text = table.concat({token, tostring(t), appkey, data}, '&')
|
||||||
|
return MD5.sum(text)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_vinfos_by_video_id(url, callback)
|
||||||
|
local vid = url:match('/v_show/id_([%w=]+)') or url:match('id_([%w=]+)') or url:match("[?&]vid=([^&]+)")
|
||||||
|
if not vid then
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local api_url = 'https://openapi.youku.com/v2/videos/show.json'
|
||||||
|
local params = '?client_id=53e6cc67237fc59a&video_id=' .. url_encode(vid) .. '&package=com.huawei.hwvplayer.youku&ext=show'
|
||||||
|
local args = build_curl_args(api_url .. params)
|
||||||
|
call_cmd_async(args, function(err, out)
|
||||||
|
if err or not out or out == '' then
|
||||||
|
msg.warn('youku show.json failed: ' .. tostring(err))
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local data = utils.parse_json(out)
|
||||||
|
if not data then
|
||||||
|
callback(nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local duration = tonumber(data.duration) or 0
|
||||||
|
local title = data.title or ''
|
||||||
|
if title and title ~= '' then DANMAKU.title = title end
|
||||||
|
callback(vid, duration)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_query_string(params)
|
||||||
|
local parts = {}
|
||||||
|
for k, v in pairs(params) do
|
||||||
|
table.insert(parts, k .. '=' .. url_encode(tostring(v)))
|
||||||
|
end
|
||||||
|
return table.concat(parts, '&')
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Helper: 构造 curl 参数(POST form data)
|
||||||
|
local function youku_build_curl_args_for_server(server)
|
||||||
|
local headers = {
|
||||||
|
'Content-Type: application/x-www-form-urlencoded',
|
||||||
|
'Referer: https://v.youku.com',
|
||||||
|
'User-Agent: ' .. user_agent,
|
||||||
|
'Cookie: ' .. server.cookie
|
||||||
|
}
|
||||||
|
local args = build_curl_args(server.url, headers)
|
||||||
|
table.insert(args, '--data-urlencode')
|
||||||
|
table.insert(args, 'data=' .. server.data)
|
||||||
|
msg.info('youku build args mat=' .. tostring(server.mat) .. ' sign=' .. tostring(server.sign) .. ' msg_b64_prefix=' .. string.sub(server.msg_b64 or '', 1, 16))
|
||||||
|
msg.verbose('youku curl args: ' .. table.concat(args, ' '))
|
||||||
|
return args
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Helper: 解析单个 youku 响应并把 danmu 加入 output_table
|
||||||
|
local function youku_parse_response(out, server, output_table)
|
||||||
|
if not out or out == '' then return 0 end
|
||||||
|
|
||||||
|
-- 优先解析外层 JSON,再解析内层被转义的 result
|
||||||
|
local parsed = nil
|
||||||
|
local outer = utils.parse_json(out)
|
||||||
|
if outer and type(outer) == 'table' then
|
||||||
|
if outer.data then
|
||||||
|
if type(outer.data) == 'string' then
|
||||||
|
local inner = utils.parse_json(outer.data)
|
||||||
|
if inner and type(inner) == 'table' then parsed = inner end
|
||||||
|
elseif type(outer.data) == 'table' then
|
||||||
|
if type(outer.data.result) == 'string' then
|
||||||
|
local inner = utils.parse_json(outer.data.result)
|
||||||
|
if inner and type(inner) == 'table' then parsed = inner end
|
||||||
|
elseif type(outer.data.result) == 'table' then
|
||||||
|
parsed = outer.data
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif type(outer.result) == 'string' then
|
||||||
|
local inner = utils.parse_json(outer.result)
|
||||||
|
if inner and type(inner) == 'table' then parsed = inner end
|
||||||
|
elseif type(outer.result) == 'table' then
|
||||||
|
parsed = outer
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not parsed then
|
||||||
|
local s, e = out:find('%b{}')
|
||||||
|
if s and e then
|
||||||
|
local snippet = out:sub(s, e)
|
||||||
|
local sn = utils.parse_json(snippet)
|
||||||
|
if sn and type(sn) == 'table' then parsed = sn end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not parsed then
|
||||||
|
msg.warn('youku parse failed for response; skipping')
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
if tostring(parsed.code) == '-1' then return 0 end
|
||||||
|
local danmus = parsed.data and parsed.data.result or parsed.result
|
||||||
|
if not danmus then return 0 end
|
||||||
|
|
||||||
|
local added = 0
|
||||||
|
for _, d in ipairs(danmus) do
|
||||||
|
local content = {}
|
||||||
|
local timepoint = tonumber(d.playat) and tonumber(d.playat)/1000 or 0
|
||||||
|
local properties = {}
|
||||||
|
if d.propertis then
|
||||||
|
local ps = utils.parse_json(d.propertis)
|
||||||
|
if ps then properties = ps end
|
||||||
|
end
|
||||||
|
local color = tonumber(properties.color) or 16777215
|
||||||
|
content.c = string.format('%s,%s,%s,25,,,', timepoint, color, 1)
|
||||||
|
content.m = d.content or ''
|
||||||
|
table.insert(output_table, content)
|
||||||
|
added = added + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
return added
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Helper: 最终保存并加载弹幕
|
||||||
|
local function youku_final_save(output_table, url, callback)
|
||||||
|
local ok = #output_table > 0
|
||||||
|
local final_json_str = utils.format_json(output_table)
|
||||||
|
save_danmaku_json(url, final_json_str)
|
||||||
|
load_danmaku(true)
|
||||||
|
callback(ok)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Helper: 根据 mat/guid/tk/vid 构造单个 server 条目
|
||||||
|
local function youku_make_server_entry(mat, guid, tk, vid)
|
||||||
|
local api_url = 'https://acs.youku.com/h5/mopen.youku.danmu.list/1.0/'
|
||||||
|
local msg_obj = {
|
||||||
|
ctime = os.time() * 1000,
|
||||||
|
ctype = 10004,
|
||||||
|
cver = 'v1.0',
|
||||||
|
guid = guid,
|
||||||
|
mat = mat,
|
||||||
|
mcount = 1,
|
||||||
|
pid = 0,
|
||||||
|
sver = '3.1.0',
|
||||||
|
type = 1,
|
||||||
|
vid = vid
|
||||||
|
}
|
||||||
|
local data_json = utils.format_json(msg_obj)
|
||||||
|
local msg_b64 = Base64.encode(data_json)
|
||||||
|
msg_obj.msg = msg_b64
|
||||||
|
msg_obj.sign = yk_msg_sign(msg_b64)
|
||||||
|
local data_wrapper = utils.format_json(msg_obj)
|
||||||
|
local t = tostring(os.time() * 1000)
|
||||||
|
local token_raw = tk['_m_h5_tk'] or ''
|
||||||
|
local token = token_raw:sub(1, 32)
|
||||||
|
local params = {
|
||||||
|
jsv = '2.5.6',
|
||||||
|
appKey = '24679788',
|
||||||
|
t = t,
|
||||||
|
sign = yk_t_sign(token, t, '24679788', data_wrapper),
|
||||||
|
api = 'mopen.youku.danmu.list',
|
||||||
|
v = '1.0',
|
||||||
|
type = 'originaljson',
|
||||||
|
dataType = 'jsonp',
|
||||||
|
timeout = '20000',
|
||||||
|
jsonpIncPrefix = 'utility'
|
||||||
|
}
|
||||||
|
local qs = build_query_string(params)
|
||||||
|
local full_url = api_url .. '?' .. qs
|
||||||
|
local cookie_header = '_m_h5_tk=' .. (tk['_m_h5_tk'] or '') .. ';_m_h5_tk_enc=' .. (tk['_m_h5_tk_enc'] or '') .. ';'
|
||||||
|
return {url = full_url, data = data_wrapper, cookie = cookie_header, mat = mat, sign = msg_obj.sign, msg_b64 = msg_b64}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Helper: 并行获取 cna 和 tk,然后调用回调 cb({cna=..., tk=...})
|
||||||
|
local function gather_cna_and_tk(cb)
|
||||||
|
local res = {}
|
||||||
|
local remaining = 2
|
||||||
|
local function maybe_done()
|
||||||
|
remaining = remaining - 1
|
||||||
|
if remaining == 0 then cb(res) end
|
||||||
|
end
|
||||||
|
get_cna(function(cna)
|
||||||
|
res.cna = cna
|
||||||
|
maybe_done()
|
||||||
|
end)
|
||||||
|
get_tk_enc(function(tk)
|
||||||
|
res.tk = tk
|
||||||
|
maybe_done()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Helper: 从已知参数发起 youku 弹幕请求并处理结果
|
||||||
|
local function start_youku_requests(source_url, vid, duration, cna, tk, callback)
|
||||||
|
local max_mat = math.floor(duration / 60) + 1
|
||||||
|
if max_mat < 1 then max_mat = 1 end
|
||||||
|
msg.verbose(string.format('vid: %s duration: %s mats: %d', vid, tostring(duration), max_mat))
|
||||||
|
|
||||||
|
local servers = {}
|
||||||
|
for mat = 0, max_mat - 1 do
|
||||||
|
table.insert(servers, youku_make_server_entry(mat, cna, tk, vid))
|
||||||
|
end
|
||||||
|
|
||||||
|
local output_table = {}
|
||||||
|
|
||||||
|
local function per_response_cb(server, err, out)
|
||||||
|
if err then
|
||||||
|
msg.warn('youku request failed: ' .. tostring(err))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not out or out == '' then return end
|
||||||
|
youku_parse_response(out, server, output_table)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function final_cb()
|
||||||
|
youku_final_save(output_table, source_url, callback)
|
||||||
|
end
|
||||||
|
|
||||||
|
parallel_requests(servers, youku_build_curl_args_for_server, per_response_cb, final_cb, {concurrency = 6, per_request_timeout = 20})
|
||||||
|
end
|
||||||
|
|
||||||
|
function load_danmaku_for_youku(path, callback)
|
||||||
|
callback = callback or function() end
|
||||||
|
local url = path or mp.get_property('stream-open-filename', '')
|
||||||
|
if not url or url == '' then
|
||||||
|
msg.error('无有效 URL')
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
get_vinfos_by_video_id(url, function(vid, duration)
|
||||||
|
if not vid then
|
||||||
|
show_message('获取优酷 video_id 失败', 3)
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
gather_cna_and_tk(function(res)
|
||||||
|
local cna = res.cna
|
||||||
|
local tk = res.tk
|
||||||
|
if not cna then
|
||||||
|
show_message('获取优酷 cna 失败', 3)
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not tk then
|
||||||
|
show_message('获取优酷 tk_enc 失败', 3)
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
start_youku_requests(url, vid, duration, cna, tk, callback)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user