This commit is contained in:
2026-08-15 17:50:48 +02:00
parent 22246060e6
commit 4dc6687de7
35 changed files with 4978 additions and 1509 deletions
+9 -2
View File
@@ -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,幂等可重复执行):
```bash
REPO=$(git -C ~/.config/mpv rev-parse --show-toplevel)
GITSTORE="$REPO/.manager"
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}"
rel="${dest#$REPO/}"
name=$(echo "$rel" | tr '/' '-')
[ -n "$name" ] || continue
[ -d "$GITSTORE/$name" ] && rm -rf "${GITSTORE:?}/$name"
mv "$gitdir" "$GITSTORE/$name"
depth=$(echo "$rel" | tr -cd '/' | wc -c)
ups=$(printf '../%.0s' $(seq 1 $((depth + 1))))
echo "gitdir: ${ups}.manager/$name" > "$dest/.git"
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` 或脚本加载失败的警告
4. 若有 `unknown key` 警告,说明对应脚本的配置项发生变化,找 `script-opts/` 下同名 `.conf` 对照脚本源码更新
+1 -1
View File
@@ -11,7 +11,7 @@ force=no
#如果 mpv 无法做到这一点,请尝试重新选择最后一个轨道,例如当信息更改时,默认 no
smart_keep=no
## 指定需忽略的特殊协议
special_protocols=["://", "^magnet:"]
#special_protocols=["://", "^magnet:"]
#视频轨道筛选项设置
#preferred_video_lang=
+3 -4
View File
@@ -1,5 +1,5 @@
## 指定弹幕服务器地址,自定义服务需兼容 dandanplay 的 api
api_server=https://api.dandanplay.net
api_server=https://danmaku-api.152468.xyz
## 指定 b 站和爱腾优的弹幕获取的兜底服务器地址,主要用于获取非动画弹幕
## 服务器可以自托管:https://github.com/lyz05/danmaku
#fallback_server=https://fc.lyz05.cn
@@ -8,7 +8,7 @@ api_server=https://api.dandanplay.net
## 注意:自定义此参数时还需要对获取到的 API Key 进行 base64 编码
#tmdb_api_key=NmJmYjIxOTZkNzIyN2UyMTIzMGM3Y2YzZjQ4MDNkZGM=
## 加载更多来自弹幕服务器上第三方的弹幕
load_more_danmaku=yes
#load_more_danmaku=yes
## 自动加载网络弹幕
auto_load=no
## 自动加载本地弹幕
@@ -38,7 +38,7 @@ fixtime=5
## 字体
fontname=danmaku
## 字体大小
fontsize=24
fontsize=28
## 透明度
opacity=0.6
## 粗体
@@ -59,4 +59,3 @@ title_replace=[{"rules":[{ "^(.-)": "%1"},{ "^.*《(.-)》": "%1" }]}]
## 指定哈希匹配中需忽略的共享盘(挂载盘)的路径/目录。支持绝对路径和相对路径,多个路径用逗号分隔
## 示例:["X:", "Z:", "F:/Download/", "Download"]
#excluded_path=
+7 -3
View File
@@ -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)
end
function url_decode(str)
local function hex_to_char(x)
return string.char(tonumber(x, 16))
end
local function url_decode(str)
if str ~= nil then
str = str:gsub('^%a[%a%d-_]+://', '')
:gsub('^%a[%a%d-_]+:\\?', '')
@@ -112,10 +112,14 @@ function url_decode(str)
if str:find('://localhost:?') then
str = str:gsub('^.*/', '')
end
str = str:gsub('[\\/:%?]*', '')
return str
str = str:gsub("%?.+", ""):gsub("%+", " ")
local last_pos = str:match('.*[\\/:%?]()')
if last_pos then
str = str:sub(last_pos)
end
end
return str
end
--create global_chapters_dir if it doesn't exist
local global_chapters_dir = mp.command_native({ "expand-path", o.global_chapters_dir })
+8 -1
View File
@@ -5,6 +5,11 @@
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 paused = mp.get_property_bool("pause")
msg.info("fix A/V sync.")
@@ -19,7 +24,9 @@ end
mp.observe_property("current-ao", "native", function(_, device)
local aid = mp.get_property_number("aid")
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()
end
end)
+60 -34
View File
@@ -20,14 +20,18 @@ local o = {
-- Specify whether to switch HDR mode only when the window is in fullscreen or window maximized
-- only works with hdr_mode = "switch", default: false
fullscreen_only = false,
-- Specify the target peak of the HDR display, default: 203
-- must be the true peak brightness of the monitor,
-- otherwise it will cause HDR content to display incorrectly
target_peak = "203",
-- Specify the target peak of the HDR display, default: 0
-- By default, the display peak information property provided by mpv-display-plugin
-- will be used: "user-data/display-info/max-luminance"
-- 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.
-- 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
-- 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",
}
options.read_options(o, _, function() end)
@@ -48,6 +52,10 @@ local state = {
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()
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"
@@ -64,7 +72,7 @@ local function apply_hdr_settings()
mp.set_property_native("icc-profile-auto", false)
mp.set_property_native("target-prim", "bt.2020")
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-colorspace-hint", "yes")
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)
query_hdr_state()
if hdr_active and o.hdr_mode ~= "noth" then
if hdr_active then
apply_hdr_settings()
resume_if_needed(paused_before)
elseif not hdr_active and o.hdr_mode ~= "noth" and
(tonumber(target_peak) ~= 203 or target_prim == "bt.2020" or target_trc == "pq") then
elseif (tonumber(target_peak) ~= 203 or target_prim == "bt.2020" or
target_trc == "pq") then
apply_sdr_settings()
end
end
local function handle_sdr_logic(paused_before, target_peak, target_prim, target_trc)
query_hdr_state()
if not hdr_active or o.hdr_mode ~= "noth" then
if (not hdr_active or not state.inverse_mapping) and
(tonumber(target_peak) ~= 203 or target_prim == "bt.2020" or target_trc == "pq") then
if not hdr_active then
if not state.inverse_mapping and (tonumber(target_peak) ~= 203 or
target_prim == "bt.2020" or target_trc == "pq") then
apply_sdr_settings()
elseif hdr_active and state.inverse_mapping then
elseif state.inverse_mapping then
reset_target_settings()
end
resume_if_needed(paused_before)
end
if hdr_active and o.hdr_mode == "pass" and state.inverse_mapping then
elseif o.hdr_mode == "pass" and state.inverse_mapping then
reset_target_settings()
end
end
@@ -153,11 +160,26 @@ end
local function switch_hdr()
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 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
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 pause_changed = false
@@ -168,8 +190,16 @@ local function switch_hdr()
local target_trc = mp.get_property_native("target-trc")
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 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)
end
@@ -189,9 +219,16 @@ local function switch_hdr()
end
handle_hdr_logic(false, target_peak, target_prim, target_trc)
elseif current_state == "sdr" then
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)
end
@@ -215,13 +252,13 @@ local function check_paramet()
local target_contrast = mp.get_property_native("target-contrast")
local colorspace_hint = mp.get_property_native("target-colorspace-hint")
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 max_luma = params and params["max-luma"]
local is_hdr = max_luma and max_luma > 203
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
mp.set_property_native("target-peak", o.target_peak)
end
@@ -241,14 +278,10 @@ local function check_paramet()
mp.set_property_native("inverse-tone-mapping", "no")
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
local function on_start()
if o.hdr_mode == "noth" or tonumber(o.target_peak) <= 203 then
if o.hdr_mode == "noth" then
return
end
local vo = mp.get_property("vo")
@@ -258,7 +291,7 @@ local function on_start()
end
file_loaded = true
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-prim", "native", check_paramet)
mp.observe_property("target-trc", "native", check_paramet)
@@ -285,13 +318,6 @@ local function on_end(event)
end
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
file_loaded = false
query_hdr_state()
+670
View File
@@ -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)
+224
View File
@@ -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
View File
@@ -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)
+6 -5
View File
@@ -54,13 +54,14 @@ local function url_decode(str)
if str:find('://localhost:?') then
str = str:gsub('^.*/', '')
end
str = str:gsub('%?.+', '')
:gsub('%+', ' ')
return str
else
return
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_compressed_file(filename)
local ext_map = {
+168 -66
View File
@@ -1,10 +1,13 @@
-- trackselect.lua
-- https://github.com/po5/trackselect
--[[
SOURCE_: https://github.com/po5/trackselect
Modify_: https://github.com/dyphire/mpv-scripts
-- Because --slang isn't smart enough.
--
-- 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 utils = require 'mp.utils'
@@ -14,9 +17,10 @@ local defaults = {
selected = nil,
best = {},
lang_score = nil,
expected_score = nil,
channels_score = -math.huge,
preferred = "jpn/japanese",
excluded = "",
excluded = "commentary/cast/staff/dub/guide",
expected = "",
id = ""
},
@@ -24,6 +28,7 @@ local defaults = {
selected = nil,
best = {},
lang_score = nil,
expected_score = nil,
preferred = "",
excluded = "",
expected = "",
@@ -33,8 +38,9 @@ local defaults = {
selected = nil,
best = {},
lang_score = nil,
expected_score = nil,
preferred = "eng",
excluded = "sign",
excluded = "sign/song",
expected = "",
id = ""
}
@@ -54,11 +60,6 @@ local options = {
-- Try to re-select the last track if mpv cannot do it e.g. when fingerprint changes
smart_keep = false,
--add above (after a comma) any protocol to disable
special_protocols = [[
["://", "^magnet:"]
]],
}
for _type, track in pairs(defaults) do
@@ -72,41 +73,82 @@ options["preferred_audio_channels"] = ""
local tracks = {}
local last = {}
local fingerprint = ""
local trackselect_ran = false
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)
for index, element in ipairs(tab) do
if string.find(val, element) then
local function hex_to_char(x)
return string.char(tonumber(x, 16))
end
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
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
return false
end
function contains(track, words, attr)
local function contains(track, words, attr)
if not track[attr] then return false end
local i = 0
if track.external then
i = 1
end
for word in string.gmatch(words:lower(), "([^/]+)") do
local w = word:match("^%s*(.-)%s*$")
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
end
end
return false
end
function preferred(track, words, attr, title)
local function preferred(track, words, attr, title)
local score = contains(track, words, attr)
if not score then
if tracks[track.type][title] == nil then
tracks[track.type][title] = -math.huge
return true
return false
end
return false
end
@@ -117,10 +159,11 @@ function preferred(track, words, attr, title)
return false
end
function preferred_or_equals(track, words, attr, title)
local function preferred_or_equals(track, words, attr, title)
local score = contains(track, words, attr)
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
end
return false
@@ -131,14 +174,14 @@ function preferred_or_equals(track, words, attr, title)
return false
end
function copy(obj)
local function copy(obj)
if type(obj) ~= "table" then return obj end
local res = {}
for k, v in pairs(obj) do res[k] = copy(v) end
return res
end
function track_layout_hash(tracklist)
local function track_layout_hash(tracklist)
local t = {}
for _, track in ipairs(tracklist) do
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")
end
function trackselect()
local fpath = mp.get_property('path')
if not options.enabled then return end
if need_ignore(options.special_protocols, fpath) then return end
tracks = copy(defaults)
local function sanitize_title(track, path, filename, media_title)
if not track or not track.title then return end
local title = filename
if is_protocol(path) then
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 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_changed = false
local found_last = {}
@@ -164,10 +246,14 @@ function trackselect()
fingerprint = new_fingerprint
tracklist_changed = true
end
for _, track in ipairs(tracklist) do
if options.smart_keep and last[track.type] ~= nil and last[track.type].lang == track.lang and
last[track.type].codec == track.codec and last[track.type].external == track.external and
last[track.type].title == track.title then
sanitize_title(track, path, filename, media_title)
if options.smart_keep and last[track.type] ~= nil and
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
options["preferred_" .. track.type .. "_lang"] = ""
options["excluded_" .. track.type .. "_words"] = ""
@@ -185,40 +271,64 @@ function trackselect()
options["preferred_" .. track.type .. "_channels"] = ""
end
end
if options["preferred_" .. track.type .. "_lang"] ~= "" or options["excluded_" .. track.type .. "_words"] ~= ""
or options["expected_" .. track.type .. "_words"] ~= "" or
if track.codec ~= "null" and options["preferred_" .. track.type .. "_lang"] ~= "" or
options["excluded_" .. track.type .. "_words"] ~= "" or
options["expected_" .. track.type .. "_words"] ~= "" or
(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
tracks[track.type].selected = track.id
if options.smart_keep then
last[track.type] = track
end
end
if track.title then
track.title = string.gsub(string.gsub(track.title, "[%(%)%.%+%-%*%?%[%]%^%$%%]", "%%%1"), filename, "")
end
if next(tracks[track.type].best) == nil or not (tracks[track.type].best.external
and tracks[track.type].best.lang ~= nil and not track.external) then
if options["excluded_" .. track.type .. "_words"] == "" or
not contains(track, options["excluded_" .. track.type .. "_words"], "title") then
if options["expected_" .. track.type .. "_words"] == "" or
contains(track, options["expected_" .. track.type .. "_words"], "title") then
local pass = true
if (next(tracks[track.type].best) == nil or not (tracks[track.type].best.external
and tracks[track.type].best.lang ~= nil and not track.external)) then
if excluded_words == "" or not contains(track, excluded_words, "title") then
local channels = false
local lang = false
if (options["preferred_" .. track.type .. "_channels"] or "") ~= "" and
preferred_or_equals(track, options["preferred_" .. track.type .. "_lang"], "lang",
"lang_score") then
channels = preferred(track, options["preferred_" .. track.type .. "_channels"],
"demux-channel-count", "channels_score")
pass = channels
if (preferred_channels or "") ~= "" and
preferred_or_equals(track, preferred_lang, "lang", "lang_score") then
channels = preferred(track, preferred_channels, "demux-channel-count", "channels_score")
end
if options["preferred_" .. track.type .. "_lang"] ~= "" then
lang = preferred(track, options["preferred_" .. track.type .. "_lang"], "lang", "lang_score")
if preferred_lang ~= "" then
lang = preferred(track, preferred_lang, "lang", "lang_score")
end
if (options["preferred_" .. track.type .. "_lang"] == "" and pass) or channels or lang or
(track.external and track.lang == nil and
(not tracks[track.type].best.external or tracks[track.type].best.lang == nil)) then
local exp_score = nil
if expected_words then
exp_score = contains(track, expected_words, "title")
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
if existing_exp == nil or exp_score > existing_exp or (exp_score == existing_exp and
((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
elseif tracks[track.type].expected_score == nil then
if (preferred_lang == "" and track.default) or channels or lang or
(track.external and track.lang == nil and next(tracks[track.type].best) == nil) then
tracks[track.type].best = track
end
end
@@ -226,9 +336,10 @@ function trackselect()
end
end
end
for _type, track in pairs(tracks) do
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
last[track.best.type] = track.best
end
@@ -236,22 +347,13 @@ function trackselect()
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
mp.add_hook("on_preloaded", 50, trackselect)
else
mp.register_event("file-loaded", trackselect)
end
if options.smart_keep then
mp.register_event("track-switched", selected_tracks)
end
mp.add_hook("on_unload", 50, function()
trackselect_ran = false
mp.unobserve_property(selected_tracks)
end)
+1 -1
View File
@@ -36,7 +36,7 @@ end
function Button:render()
local visibility = self:get_visibility()
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 is_clickable = self.is_clickable and self.on_click ~= nil
+9 -5
View File
@@ -108,6 +108,7 @@ function Menu:init(data, callback, opts)
self.opts = opts or {}
self.offset_x = 0 -- Used for submenu transition animation.
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.min_width = nil
self.item_spacing = 1
@@ -700,7 +701,8 @@ end
---@param shortcut? 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)
end
if self.is_dragging then
@@ -1456,16 +1458,18 @@ function Menu:render()
}
-- Select hovered item
if is_current and self.mouse_nav and item.selectable ~= false
-- Do not select items if cursor is moving towards a submenu
and (not submenu_rect or not cursor:direction_to_rectangle_distance(submenu_rect))
if is_current and self.mouse_nav
and (submenu_is_hovered or get_point_to_rectangle_proximity(cursor, item_rect_hitbox) <= 0) then
self.mouse_hovered_index = index
-- Do not select items if cursor is moving towards a submenu
if item.selectable ~= false and (not submenu_rect or not cursor:direction_to_rectangle_distance(submenu_rect)) then
menu.selected_index = index
if not is_selected then
is_selected = true
request_render()
end
end
end
local has_background = is_selected or item.active
local next_item = menu.items[index + 1]
@@ -1555,7 +1559,7 @@ function Menu:render()
-- Select action on cursor hover
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)
end))
blur_action_index = false
+4 -4
View File
@@ -249,7 +249,7 @@ function TopBar:render()
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
cursor:zone('primary_down', rect, button.command)
cursor:zone('primary_click', rect, button.command)
local bg_size = self.size - 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
-- 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
-- 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}
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
ass:rect(title_rect.ax, title_rect.ay, title_rect.bx, title_rect.by, {
@@ -415,7 +415,7 @@ function TopBar:render()
-- Click action
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
end
+30 -2
View File
@@ -4,6 +4,8 @@ local cursor = {
x = math.huge,
y = math.huge,
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`.
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.
@@ -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)
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 shortcut Shortcut
---@param cb? fun(shortcut: Shortcut)
function cursor:create_handler(event, shortcut, cb)
return function()
if self.disabled then return end
if cb then cb(shortcut) end
self:trigger(event, shortcut)
end
@@ -416,7 +434,7 @@ end
-- Movement
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
cursor:leave()
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
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]
if touch then
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', '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
+3 -4
View File
@@ -54,15 +54,14 @@ do
-- alphanum sorting for humans in Lua
-- http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua
local function padnum(n, d)
return #d > 0 and ('%03d%s%.12f'):format(#n, n, tonumber(d) / (10 ^ #d))
or ('%03d%s'):format(#n, n)
local function padnum(n)
return ("%03d%s"):format(#n, n)
end
local function sort_lua(strings)
local tuples = {}
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
table.sort(tuples, function(a, b)
return a[1] == b[1] and #b[2] < #a[2] or a[1] < b[1]
+99 -29
View File
@@ -5,6 +5,9 @@
> [!WARNING]
> 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]
> 已添加对mpv内部 `mp.input`的支持,在uosc不可用时通过键绑定调用此方式渲染菜单
>
@@ -63,12 +66,44 @@
</details>
## 目录
- [项目简介](#项目简介)
- [主要功能](#主要功能)
- [安装](#安装)
- [下载](#下载)
- [基本配置](#基本配置)
- [uosc控件配置](#uosc控件配置)
- [绑定快捷键(可选)](#绑定快捷键可选)
- [拓展功能(可选)](#拓展功能可选)
- [从弹幕源向当前弹幕添加新弹幕内容(从网络url或本地添加弹幕)](#从弹幕源向当前弹幕添加新弹幕内容可选)
- [弹幕源延迟设置](#弹幕源延迟设置可选)
- [实时修改弹幕样式](#实时修改弹幕样式可选)
- [弹幕设置总菜单](#弹幕设置总菜单可选)
- [设置弹幕延迟(可选)](#设置弹幕延迟可选)
- [保存当前视频弹幕(可选)](#保存当前视频弹幕可选)
- [清空当前视频关联的弹幕源(可选)](#清空当前视频关联的弹幕源可选)
- [检查脚本更新(可选)](#检查脚本更新可选)
- [可配置选项(可选)](#可配置选项可选)
- [弹幕加载相关](#弹幕加载相关)
- [弹幕显示相关](#弹幕显示相关)
- [弹幕解析服务相关](#弹幕解析服务相关)
- [插件配置相关](#插件配置相关)
- [自定义弹幕样式相关配置](#自定义弹幕样式相关配置)
- [插件自定义属性](#插件自定义属性)
- [常见问题](#常见问题)
- [特别感谢](#特别感谢)
- [相关项目](#相关项目)
## 安装
### 下载
一般的mpv配置目录结构大致如下
> [!NOTE]
> Windows 系统上等价全局配置路径`%APPDATA%/mpv/`,也可使用 mpv.exe 所在目录的 portable_config 文件夹(便携配置路径)
```
~/.config/mpv
├── 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`
@@ -472,7 +507,7 @@ save_danmaku
#### 功能说明
当文件关闭时自动保存弹幕文件(xml格式)至视频同目录,保存的弹幕文件名与对应的视频文件名相同。配合[autoload_local_danmaku选项](#autoload_local_danmaku)可以实现弹幕自动保存到本地并且下次播放时自动加载本地保存的弹幕。此功能默认禁用。
当文件关闭时自动保存弹幕文件(xml格式),保存的弹幕文件名与对应的视频文件名相同。默认保存至视频同目录,配合[autoload_local_danmaku选项](#autoload_local_danmaku)可以实现弹幕自动保存到本地并且下次播放时自动加载本地保存的弹幕。此功能默认禁用。
> **⚠️NOTE**
>
@@ -492,35 +527,30 @@ save_danmaku
save_danmaku=yes
```
</details>
如需保存到指定文件夹,请提前创建目标文件夹,然后配置 `save_danmaku_path``save_danmaku_path_mode`。插件不会自动创建目录。
---
保存本地媒体到指定文件夹时,文件名会包含视频父目录名,用于减少不同目录下同名视频的弹幕文件冲突。例如 `动画/01.mkv` 会保存为类似 `动画_01.xml`。网络媒体保存到指定文件夹时仍使用媒体标题作为文件名。
<details>
<summary>
`save_danmaku_path_mode` 可选值如下:
~~add_from_source~~
- `local`:默认值,仅本地媒体保存到 `save_danmaku_path`,网络媒体仍不保存
- `url`:仅网络媒体保存到 `save_danmaku_path`,本地媒体仍保存到视频同目录
- `all`:本地媒体和网络媒体都保存到 `save_danmaku_path`
> ~~开关记录通过 从弹幕源向当前弹幕添加新弹幕内容 关联过的弹幕源并自动加载(已废除)~~
</summary>
### add_from_source
> **⚠️NOTE**
>
> 该可选配置项在Release v1.2.0之后已废除。现在通过 `从弹幕源向当前弹幕添加新弹幕内容`功能关联过的弹幕源被记录,并且下次播放同一个视频的时候自动关联并加载所有添加过的弹幕源,这样的行为已经成为了插件的默认行为,不需要再通过 `add_from_source`来开启。在[从源获取弹幕](#从弹幕源向当前弹幕添加新弹幕内容可选)菜单中可以可视化地管理所有添加过的弹幕源。
#### 功能说明
开启此选项后,通过 `从弹幕源向当前弹幕添加新弹幕内容`功能关联过的弹幕源会被记录,并且下次播放同一个视频的时候会自动关联并加载添加过的弹幕源。
#### 使用方法
想要开启此选项,请在mpv配置文件夹下的 `script-opts`中创建 `uosc_danmaku.conf`文件并添加如下内容:
例如,仅将网络媒体的弹幕保存到 `~~/danmaku`
```
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>
@@ -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>
<summary>
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**
>
> 请确保自定义服务的 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`文件并自定义如下内容:
```
api_server=https://api.dandanplay.net
api_server=https://danmaku-api.152468.xyz
```
</details>
@@ -813,7 +878,7 @@ user_agent
> **⚠️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
@@ -1015,6 +1080,9 @@ blacklist_path=
- `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/danmaku-switch-on`
`user-data/uosc_danmaku/danmaku-switch-on`属性中可以获取到表示当前弹幕开关状态的布尔值,具体用法可以参考[此issue](https://github.com/Tony15246/uosc_danmaku/issues/362)
## 常见问题
### 来自弹弹play的弹幕源问题如何从根源进行调整解决
@@ -1049,10 +1117,12 @@ blacklist_path=
- 弹幕格式解析转换:[DanmakuConvert](https://github.com/timerring/DanmakuConvert)
- 简繁转换:[OpenCC](https://github.com/BYVoid/OpenCC)
- 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)
- 巴哈姆特在线播放弹幕获取实现参考:[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)
- [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)~~ 相关功能主仓库已实现
+226 -105
View File
@@ -21,31 +21,43 @@ end
-- 写入history.json
-- 读取episodeId获取danmaku
function set_episode_id(input, from_menu)
function set_episode_id(input, from_menu, api_server)
from_menu = from_menu or false
DANMAKU.source = "dandanplay"
local api_server = options.api_server
local selected_server = api_server
for url, source in pairs(DANMAKU.sources) do
if source.from == "api_server" then
if not source.from_history then
DANMAKU.sources[url] = nil
else
DANMAKU.sources[url]["data"] = nil
api_server = source.api_server or options.api_server
end
end
end
local episodeId = tonumber(input)
local main_url = api_server .. "/api/v2/comment/" .. episodeId .. "?withRelated=true&chConvert=0"
add_source_to_history(main_url, { from = "api_server", api_server = api_server })
write_history(episodeId, api_server)
if not api_server then
if DANMAKU.api_server ~= nil then
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()
fetch_danmaku(episodeId, from_menu, api_server)
fetch_danmaku(episodeId, from_menu, selected_server)
end
-- 回退使用额外的弹幕获取方式
function get_danmaku_fallback(query)
local function do_fallback()
if options.fallback_server == "" then return end
local url = options.fallback_server .. "/?ac=dm&url=" .. query
msg.verbose("尝试获取弹幕:" .. url)
@@ -53,6 +65,16 @@ function get_danmaku_fallback(query)
if not args then return end
fetch_danmaku_data(args, function(data)
if data ~= nil and data["xml"] ~= nil then
if DANMAKU.sources[query] ~= nil then
DANMAKU.sources[query]["data"] = data["xml"]
else
DANMAKU.sources[query] = {from = "user_custom", data = data["xml"]}
end
load_danmaku(true)
return
end
if not data or not data["comments"] or data["count"] <= 1 then
msg.info("备用服务器无数据或返回格式不正确")
show_message("备用服务器无数据或返回格式不正确", 3)
@@ -64,6 +86,51 @@ function get_danmaku_fallback(query)
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
-- 返回弹幕请求参数
function make_danmaku_request_args(method, url, headers, body)
local args = {
@@ -91,6 +158,8 @@ function make_danmaku_request_args(method, url, headers, body)
table.insert(args, 'Content-Type: application/json')
end
table.insert(args, '--compressed')
if url:find("api%.dandanplay%.") then
local time = os.time()
local appid = "UgjRIH45lE1BBLNmir1WKw=="
@@ -153,8 +222,8 @@ local function normalize_danmaku_response(d)
end
-- 尝试通过解析文件名匹配剧集
local function match_episode(animeTitle, bangumiId, episode_num)
local url = options.api_server .. "/api/v2/bangumi/" .. bangumiId
local function match_episode(animeTitle, bangumiId, episode_num, api_server)
local url = api_server .. "/api/v2/bangumi/" .. bangumiId
local args = make_danmaku_request_args("GET", url)
if args == nil then
@@ -162,7 +231,6 @@ local function match_episode(animeTitle, bangumiId, episode_num)
end
call_cmd_async(args, function(error, json)
async_running = false
if error then
show_message("HTTP 请求失败,打开控制台查看详情", 5)
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
DANMAKU.anime = animeTitle
DANMAKU.episode = episode.episodeTitle
set_episode_id(episode.episodeId)
set_episode_id(episode.episodeId, nil, api_server)
break
end
end
@@ -188,9 +256,7 @@ local function match_episode(animeTitle, bangumiId, episode_num)
end
local function match_anime()
local animes = {}
local anime_type = "tvseries"
local type_count = 0
local title, season_num, episode_num = parse_title()
if not episode_num then
msg.info("无法解析剧集信息")
@@ -201,44 +267,49 @@ local function match_anime()
anime_type = "ova"
end
-- 并发在多个 api_server 上搜索,遇到第一个可接受的匹配就取消其余请求
local encoded_query = url_encode(title)
local url = options.api_server .. "/api/v2/search/anime"
local params = "keyword=" .. encoded_query
local full_url = url .. "?" .. params
local args = make_danmaku_request_args("GET", full_url)
local servers = get_api_server_list(options.api_server)
if not args then return end
local matched = false
local cancel_fn = nil
call_cmd_async(args, function(error, json)
async_running = false
if error then
show_message("HTTP 请求失败,打开控制台查看详情", 5)
msg.error(error)
return
local function build_args(server)
local url = server .. "/api/v2/search/anime"
local full_url = url .. "?keyword=" .. encoded_query
return make_danmaku_request_args("GET", full_url)
end
local data = utils.parse_json(json)
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
end
local data = utils.parse_json(out)
if not data or not data.animes then
msg.info("无结果")
return
end
local local_candidates = {}
for _, anime in ipairs(data.animes) do
if anime.type == anime_type then
type_count = type_count + 1
table.insert(animes, anime)
table.insert(local_candidates, anime)
end
end
if type_count == 1 then
match_episode(animes[1].animeTitle, animes[1].bangumiId, episode_num)
elseif type_count > 1 and season_num then
if #local_candidates == 1 then
matched = true
local a = local_candidates[1]
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 target_title = title
if tonumber(season_num) > 1 then
target_title = title .. "" .. number_to_chinese(season_num) .. ""
end
for _, anime in ipairs(animes) do
for _, anime in ipairs(local_candidates) do
local animeTitle = tostring(anime.animeTitle or "")
animeTitle = animeTitle:gsub("^%s*(.-)%s*$", "%1")
:gsub("%s*%(.-%)%s*$", "")
@@ -253,17 +324,24 @@ local function match_anime()
best_match = anime
end
end
if best_match and best_score >= 0.75 then
matched = true
msg.info(("模糊匹配选中: %s (score=%.2f)"):format(best_match.animeTitle, best_score))
match_episode(best_match.animeTitle, best_match.bangumiId, episode_num)
else
msg.info("匹配到多个结果,但相似度不足,请手动搜索")
match_episode(best_match.animeTitle, best_match.bangumiId, episode_num, server)
if cancel_fn then pcall(cancel_fn) end
return
end
else
end
-- 未找到可接受匹配,继续等待其他服务器的返回
end
local function final_cb()
if not matched then
msg.info("没有找到合适的匹配结果")
end
end)
end
cancel_fn = parallel_requests(servers, build_args, per_response, final_cb, { concurrency = 5, per_request_timeout = 15 })
end
-- 执行哈希匹配获取弹幕
@@ -271,7 +349,7 @@ local function match_file(file_path, file_name, callback)
-- 计算文件哈希
local hash = nil
local file_info = utils.file_info(file_path)
if file_info and file_info.size > 16 * 1024 * 1024 then
if file_info and file_info.size >= 16 * 1024 * 1024 then
local file, error = io.open(normalize(file_path), 'rb')
if file and not error then
local m = MD5.new()
@@ -300,50 +378,66 @@ local function match_file(file_path, file_name, callback)
file_name = title
end
local url = options.api_server .. "/api/v2/match"
local args = make_danmaku_request_args("POST", url, {
["Content-Type"] = "application/json"
}, {
local servers = get_api_server_list(options.api_server)
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,
fileHash = hash or "a1b2c3d4e5f67890abcd1234ef567890",
matchMode = "hashAndFileName"
}
)
})
end
if not args then return end
call_cmd_async(args, function(error, json)
async_running = false
if error then
show_message("HTTP 请求失败,打开控制台查看详情", 5)
callback(error)
local function per_response(server, err, out)
if matched then return end
if err then
msg.debug(("match failed for %s: %s"):format(server, tostring(err)))
return
end
local data = utils.parse_json(json)
local data = utils.parse_json(out)
if not data or not data.isMatched then
callback("没有匹配的剧集")
return
end
matched = true
DANMAKU.anime = data.matches[1].animeTitle
DANMAKU.episode = data.matches[1].episodeTitle
-- 获取并加载弹幕数据
set_episode_id(data.matches[1].episodeId)
end)
set_episode_id(data.matches[1].episodeId, nil, server)
if cancel_fn then pcall(cancel_fn) 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
-- 异步获取弹幕数据
function fetch_danmaku_data(args, callback)
call_cmd_async(args, function(error, json)
async_running = false
if error then
show_message("获取数据失败", 3)
msg.error("HTTP 请求失败:" .. error)
return
end
local data = utils.parse_json(json)
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)
end)
end
@@ -359,6 +453,26 @@ function save_danmaku_data(comments, query, danmaku_source)
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)
local danmaku_list = parse_danmaku_file(downloaded_file)
if file_exists(downloaded_file) then
@@ -371,39 +485,6 @@ function save_danmaku_downloaded(url, downloaded_file)
end
end
-- 处理弹幕数据
function handle_danmaku_data(query, data, from_menu)
-- 如果没有数据,进行重试
if not data or not data["comments"] or data["count"] <= 1 then
show_message("服务器无缓存数据,再次尝试请求", 10)
msg.verbose("服务器无缓存数据,再次尝试请求")
-- 等待 2 秒后重试
local start = os.time()
while os.time() - start < 2 do
-- 空循环,等待 2 秒
end
-- 重新发起请求
local url = options.api_server .. "/api/v2/extcomment?url=" .. url_encode(query)
local args = make_danmaku_request_args("GET", url)
if args == nil then
return
end
fetch_danmaku_data(args, function(retry_data)
if not retry_data or not retry_data["comments"] or retry_data["count"] <= 1 then
get_danmaku_fallback(query)
return
end
save_danmaku_data(retry_data["comments"], query, "user_custom")
load_danmaku(from_menu)
end)
else
save_danmaku_data(data["comments"], query, "user_custom")
load_danmaku(from_menu)
end
end
-- 处理获取到的数据
function handle_fetched_danmaku(data, url, from_menu)
if data and data["comments"] then
@@ -426,7 +507,7 @@ end
-- 匹配弹幕库 comment, 仅匹配dandan本身弹幕库
-- 通过danmaku apiurl+id获取弹幕
function fetch_danmaku(episodeId, from_menu, api_server)
local url = (api_server or options.api_server) .. "/api/v2/comment/" .. episodeId .. "?withRelated=true&chConvert=0"
local url = api_server .. "/api/v2/comment/" .. episodeId .. "?withRelated=true&chConvert=0"
show_message("弹幕加载中...", 30)
msg.verbose("尝试获取弹幕:" .. url)
local args = make_danmaku_request_args("GET", url)
@@ -499,18 +580,60 @@ end
--通过输入源url获取弹幕库
function add_danmaku_source_online(query, from_menu)
set_danmaku_button()
local url = options.api_server .. "/api/v2/extcomment?url=" .. url_encode(query)
show_message("弹幕加载中...", 30)
msg.verbose("尝试获取弹幕:" .. url)
local args = make_danmaku_request_args("GET", url)
msg.verbose("尝试获取弹幕:" .. query)
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
end
fetch_danmaku_data(args, function(data)
handle_danmaku_data(query, data, from_menu)
end)
local matched = false
local cancel_fn = nil
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
-- 将弹幕转换为 Lua table
@@ -578,8 +701,6 @@ function get_danmaku_with_hash(file_name, file_path)
end
call_cmd_async(arg, function(error)
async_running = false
file_path = utils.join_path(DANMAKU_PATH, temp_file)
match_file(file_path, file_name, function(error)
+114 -43
View File
@@ -1,8 +1,11 @@
local utils = require 'mp.utils'
local msg = require 'mp.msg'
local cached_series_playlinks = {}
local Source = {
["b 站"] = "bilibili1",
["芒果TV"] = "imgo",
["腾讯"] = "qq",
["爱奇艺"] = "qiyi",
["优酷"] = "youku",
@@ -83,6 +86,31 @@ local function query_tmdb(title, class, menu)
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 url = string.format("https://api.web.360kan.com/v1/detail?cat=%s&id=%s&site=%s",
cat, id, site)
@@ -107,6 +135,53 @@ local function get_number(cat, id, site)
return nil
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 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
msg.error("Failed to fetch episodesv2: " .. (res.stderr or "unknown error"))
msg.warn("Failed to fetch episodesv2: " .. (res.stderr or "unknown error"))
return nil
end
local data_text = res.stdout or ""
-- 兼容 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)
local parsed = utils.parse_json(res.stdout)
if not parsed then
msg.error("episodesv2: 解析返回失败: " .. (res.stdout or ""))
msg.warn("episodesv2: 解析返回失败: " .. (res.stdout or ""))
return nil
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
local seriesHTML = parsed.data[1] and parsed.data[1].seriesHTML
if seriesHTML and seriesHTML.seriesPlaylinks then
for i, ep in ipairs(seriesHTML.seriesPlaylinks) do
local episode_url = nil
if type(ep) == 'string' then
episode_url = ep
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 })
local rows = build_episode_rows(seriesHTML.seriesPlaylinks)
if rows then
for _, r in ipairs(rows) do
table.insert(episodes, { index = tonumber(r.index), url = r.url })
end
end
end
@@ -170,7 +231,7 @@ function get_details(class, id, site, title, year, number, episodenum)
local menu_title = "剧集信息"
local footnote = "使用 / 打开筛选"
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
show_message(message, 3)
end
@@ -188,12 +249,27 @@ function get_details(class, id, site, title, year, number, episodenum)
local items = {}
local episodes = nil
local episode_rows = nil
-- 优先尝试使用搜索时缓存的 seriesPlaylinks(若存在且站点匹配)
if cat == 2 or cat == 4 then
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
-- 若未命中缓存,则继续使用 episodesv2/v1 的原有流程
if not episode_rows then
if cat == 2 or cat == 4 then
episodes = get_episodes_v2(cat, id, site)
end
-- 统一构建 episode_rows:优先使用 episodesv2 返回的数据,否则使用 v1/detail
local episode_rows = nil
if episodes then
episode_rows = {}
for _, ep in ipairs(episodes) do
@@ -214,18 +290,8 @@ function get_details(class, id, site, title, year, number, episodenum)
return
end
local url = string.format("https://api.web.360kan.com/v1/detail?cat=%s&id=%s&start=1&end=%s&site=%s",
cat, id, number, 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
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)
@@ -235,13 +301,6 @@ function get_details(class, id, site, title, year, number, episodenum)
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 = {}
for _, it in ipairs(result.data.allepidetail[site]) do
table.insert(episode_rows, { index = tostring(it.playlink_num), url = it.url })
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
for _, item in ipairs(result.data.longData.rows) do
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
if item.playlinks[source_id] then
table.insert(items, {
@@ -375,7 +446,7 @@ function query_extra(name, class)
}
menu.cmd = { "script-message-to", mp.get_script_name(), "search-anime-event" }
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
show_message(message, 30)
end
+68 -232
View File
@@ -1,4 +1,4 @@
VERSION = "2.0.0"
VERSION = "2.2.0"
mp.commandv('script-message', 'uosc_danmaku-version', VERSION)
@@ -21,6 +21,13 @@ require("modules/update")
require("apis/dandanplay")
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/"
HISTORY_PATH = mp.command_native({"expand-path", options.history_path})
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)
HAS_DANMAKU = string.format("user-data/%s/has-danmaku", mp.get_script_name())
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({
0x00,0x01,0x02,0x03,0x04,
0x05,0x06,0x07,0x08,0x09,
@@ -92,7 +103,7 @@ end
function set_danmaku_button()
if get_danmaku_visibility() then
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", "on")
toggle_danmaku_switch("on")
end
end
@@ -105,51 +116,7 @@ function show_loaded(init)
else
show_message("弹幕加载成功,共计" .. #COMMENTS .. "条弹幕", 3)
end
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 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
mp.set_property_native(DANMAKU_COUNT, #COMMENTS)
end
-- 获取指定时间的延迟
@@ -287,11 +254,13 @@ local function set_danmaku_delay(dly, time, specific_source)
end
end
if not specific_source then
if dly == 0 then
DELAY = 0
else
DELAY = DELAY + dly
end
end
if ENABLED and COMMENTS ~= nil then
render()
@@ -310,9 +279,15 @@ local function set_danmaku_delay(dly, time, specific_source)
rebuild_convert_timer = nil
end)
if specific_source then
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
local function clear_source()
local path = mp.get_property("path")
@@ -430,11 +405,6 @@ function add_source_to_history(add_url, add_source)
local record = history[path]["sources"][add_url]
record.from = add_source.from or "user_custom"
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 {})
if #delay_segments > 0 then
@@ -460,6 +430,7 @@ function read_danmaku_source_record(path)
local history = utils.parse_json(history_json) or {}
local record = history[path]
if not record or not record.sources then return end
local sources = record.sources
@@ -489,7 +460,6 @@ function read_danmaku_source_record(path)
blocked = blocked,
delay_segments = delay_segments,
from_history = true,
api_server = data.api_server,
}
end
else
@@ -519,7 +489,6 @@ function read_danmaku_source_record(path)
blocked = blocked,
delay_segments = delay_segments,
from_history = true,
api_server = record.api_server,
}
upgraded_sources[source] = shallow_copy(DANMAKU.sources[source])
@@ -532,14 +501,48 @@ function read_danmaku_source_record(path)
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)
local path = mp.get_property("path")
local dir = get_parent_directory(path) or ""
local filename = mp.get_property('filename/no-ext')
local danmaku_out = utils.join_path(dir, filename .. ".xml")
-- 排除网络播放场景
if not path or is_protocol(path) or (not file_exists(danmaku_out)
local filename = is_protocol(path) and mp.get_property("media-title") or mp.get_property('filename/no-ext')
local danmaku_out = get_save_danmaku_output(path, filename)
if not danmaku_out or (not file_exists(danmaku_out)
and not is_writable(danmaku_out)) then
show_message("此弹幕文件不支持保存至本地")
msg.warn("此弹幕文件不支持保存至本地")
@@ -561,176 +564,6 @@ function load_danmaku(from_menu, no_osd)
render_danmaku(from_menu, no_osd)
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)
if path:find('bilibili.com') or path:find('bilivideo.c[nom]+') then
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_fname = history_dir.fname
local history_extra = history_dir.extra
local history_api_server = history_dir.api_server
local playing_number = nil
if history_fname then
@@ -794,6 +628,7 @@ function auto_load_danmaku(path, dir, filename, number)
if playing_number ~= nil then
local x = playing_number - history_number --获取集数差值
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)
msg.verbose("自动加载上次匹配的弹幕")
if history_id then
@@ -881,7 +716,7 @@ mp.register_event("file-loaded", function()
return
end
if ENABLED and COMMENTS == nil and not async_running then
if ENABLED and COMMENTS == nil and not is_async_running() then
init(path)
end
end)
@@ -914,7 +749,8 @@ end)
mp.register_script_message("show_danmaku_keyboard", function()
ENABLED = not ENABLED
if ENABLED then
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", "on")
toggle_danmaku_switch("on")
if COMMENTS == nil then
show_message("加载弹幕初始化...", 3)
set_danmaku_visibility(true)
@@ -926,7 +762,7 @@ mp.register_script_message("show_danmaku_keyboard", function()
end
else
show_message("关闭弹幕", 2)
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", "off")
toggle_danmaku_switch("off")
hide_danmaku_func()
end
end)
+322
View File
@@ -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
+333 -88
View File
@@ -5,84 +5,267 @@ local unpack = unpack or table.unpack
input_loaded, input = pcall(require, "mp.input")
uosc_available = false
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)
local encoded_query = url_encode(query)
local url = options.api_server .. "/api/v2/search/anime"
local params = "keyword=" .. encoded_query
local full_url = url .. "?" .. params
local server_metas = get_api_server_list(options.api_server, true)
local servers = {}
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 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 menu_type = "menu_anime"
local menu_title = "在此处输入番剧名称"
local footnote = "使用enter或ctrl+enter进行搜索"
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
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
show_message(message, 30)
end
msg.verbose("尝试获取番剧数据:" .. full_url)
local args = make_danmaku_request_args("GET", full_url)
if args == nil then
return
show_message(initial_message, 30)
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 message = "获取数据失败"
local build_args = make_build_args(encoded_query)
-- 构造 ctx,用于 handle_response 闭包访问和修改共享状态
local ctx = {
items = items,
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
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
show_message(message, 3)
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
update_menu_uosc(menu_type, menu_title, message, footnote, menu_cmd, query)
else
show_message(message, 3)
end
msg.info("无结果")
return
end
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
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()
latest_menu_anime = utils.format_json(items)
open_menu_select(items)
open_menu_select(final_items)
end)
end
end
end, { concurrency = 5, per_request_timeout = 30 })
active_request_cancel = cancel_fn
active_request_type = menu_type
end
function get_episodes(animeTitle, bangumiId)
local url = options.api_server .. "/api/v2/bangumi/" .. bangumiId
function get_episodes(animeTitle, bangumiId, api_server)
local url = api_server .. "/api/v2/bangumi/" .. bangumiId
local items = {}
local message = "加载数据中..."
@@ -91,7 +274,9 @@ function get_episodes(animeTitle, bangumiId)
local footnote = "使用 / 打开筛选"
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
show_message(message, 30)
end
@@ -102,20 +287,26 @@ function get_episodes(animeTitle, bangumiId)
return
end
local res = mp.command_native({ name = 'subprocess', capture_stdout = true, capture_stderr = true, args = args })
request_cancelled = false
active_request_type = menu_type
active_request_cancel = call_cmd_async(args, function(err, stdout)
active_request_cancel = nil
if request_cancelled then
return
end
if not res.status or res.status ~= 0 then
if err then
local message = "获取数据失败"
if uosc_available then
update_menu_uosc(menu_type, menu_title, message, footnote)
else
show_message(message, 3)
end
msg.error("HTTP 请求失败:" .. res.stderr)
msg.error("HTTP 请求失败:" .. tostring(err))
return
end
local response = utils.parse_json(res.stdout)
local response = utils.parse_json(stdout)
if not response or not response.bangumi or not response.bangumi.episodes then
local message = "无结果"
if uosc_available then
@@ -139,7 +330,7 @@ function get_episodes(animeTitle, bangumiId)
title = episode.episodeTitle,
hint = episode.episodeNumber,
value = { "script-message-to", mp.get_script_name(), "load-danmaku",
animeTitle, episode.episodeTitle, episode.episodeId },
animeTitle, episode.episodeTitle, episode.episodeId, api_server },
keep_open = false,
selectable = true,
})
@@ -149,13 +340,17 @@ function get_episodes(animeTitle, bangumiId)
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
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 = {}
if type(menu_item) == "string" then
table.insert(items, {
@@ -165,7 +360,7 @@ function update_menu_uosc(menu_type, menu_title, menu_item, menu_footnote, menu_
keep_open = true,
selectable = false,
align = "center",
icon = "spinner",
icon = message_icon or "",
})
else
items = menu_item
@@ -181,8 +376,19 @@ function update_menu_uosc(menu_type, menu_title, menu_item, menu_footnote, menu_
search_suggestion = query,
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)
mp.commandv("script-message-to", "uosc", "open-menu", json_props)
mp.commandv("script-message-to", "uosc", cmd, json_props)
return json_props
end
@@ -200,6 +406,7 @@ function open_menu_select(menu_items, is_time)
items = item_titles,
submit = function(id)
input.terminate()
perform_cancel_active_request()
local v = item_values[id]
if type(v) == 'table' then
mp.commandv(unpack(v))
@@ -207,6 +414,11 @@ function open_menu_select(menu_items, is_time)
mp.command(v)
end
end,
closed = function()
show_message("", 0)
input.terminate()
perform_cancel_active_request()
end,
})
end
@@ -264,7 +476,9 @@ function open_input_menu()
if uosc_available then
open_input_menu_uosc()
elseif input_loaded then
mp.add_timeout(0.01, function()
open_input_menu_get()
end)
end
end
@@ -283,23 +497,29 @@ function open_add_menu_get()
local serial = 0
for url, source in pairs(DANMAKU.sources) do
if source.data then
serial = serial + 1
local action, text
if source.from == "api_server" then
action = source.blocked and "unblock" or "block"
text = string.format(" [%02d] %s [来源:弹幕服务器%s] ", serial, url,
serial = serial + 1
local action = source.blocked and "unblock" or "block"
local text = string.format(" [%02d] %s [来源:弹幕服务器%s] ", serial, url,
source.blocked and "(已屏蔽)" or "(未屏蔽)")
else
action = "delete"
text = string.format(" [%02d] %s [来源:用户添加] ", serial, url)
end
local style = (tonumber(select_num) == serial) and
"{\\c&HFFDE7F&\\b1}" or (action == "unblock" and "{\\c&H4C4CC3&\\b0}" or "{\\c&HCCCCCC&\\b0}")
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
serial = serial + 1
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
end
@@ -386,7 +606,7 @@ function open_add_menu_uosc()
local sources = {}
for url, source in pairs(DANMAKU.sources) do
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.blocked then
item.hint = "来源:弹幕服务器(已屏蔽)"
@@ -397,7 +617,17 @@ function open_add_menu_uosc()
end
else
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
table.insert(sources, item)
end
@@ -421,7 +651,9 @@ function open_add_menu()
if uosc_available then
open_add_menu_uosc()
elseif input_loaded then
mp.add_timeout(0.01, function()
open_add_menu_get()
end)
end
end
@@ -915,7 +1147,7 @@ function open_delay_menu_uosc(source_url, status)
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.active = url == source_url
table.insert(sources, item)
@@ -1013,7 +1245,10 @@ function open_add_total_menu_select()
prompt = '选择:',
items = item_titles,
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
@@ -1026,6 +1261,10 @@ function open_add_total_menu()
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(
"script-message-to",
@@ -1114,11 +1353,12 @@ mp.register_script_message("set", function(prop, value)
hide_danmaku_func()
end
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", value)
toggle_danmaku_switch(value)
end)
-- 注册函数给 uosc 按钮使用
mp.register_script_message("search-anime-event", function(query)
perform_cancel_active_request()
if uosc_available then
mp.commandv("script-message-to", "uosc", "close-menu", "menu_danmaku")
end
@@ -1129,19 +1369,20 @@ mp.register_script_message("search-anime-event", function(query)
get_animes(query)
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
mp.commandv("script-message-to", "uosc", "close-menu", "menu_anime")
end
get_episodes(animeTitle, bangumiId)
get_episodes(animeTitle, bangumiId, api_server)
end)
-- Register script message to show the input menu
mp.register_script_message("load-danmaku", function(animeTitle, episodeTitle, episodeId)
mp.register_script_message("load-danmaku", function(animeTitle, episodeTitle, episodeId, api_server)
ENABLED = true
DANMAKU.anime = animeTitle
DANMAKU.episode = episodeTitle
set_episode_id(episodeId, true)
set_episode_id(episodeId, true, api_server)
end)
mp.register_script_message("add-source-event", function(query)
@@ -1177,6 +1418,10 @@ mp.register_script_message("open-latest-menu-anime", function ()
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)
local event = utils.parse_json(query)
if event ~= nil then
@@ -1273,7 +1518,7 @@ mp.register_script_message("setup-source-delay", function(query, text)
end
local delay = parse_delay_input(text)
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.add_timeout(0.1, function()
open_delay_menu(query, "refresh")
+9 -1
View File
@@ -3,7 +3,9 @@ local opt = require("mp.options")
-- 选项
options = {
-- 指定弹幕服务器地址,自定义服务需兼容 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 站和爱腾优的弹幕获取的兜底服务器地址,主要用于获取非动画弹幕
-- 可用: https://api.danmu.icuhttps://dmku.hls.one
fallback_server = "https://api.danmu.icu",
@@ -15,6 +17,10 @@ options = {
autoload_local_danmaku = false,
autoload_for_url = false,
save_danmaku = false,
-- 指定弹幕保存目录。为空时保存到视频同目录;目录需要用户提前创建
save_danmaku_path = "",
-- 指定 save_danmaku_path 的应用范围:local / url / all
save_danmaku_path_mode = "local",
user_agent = "mpv_danmaku/1.0",
proxy = "",
-- 可选:向 HTTP 请求传递 cookie.txt 文件路径
@@ -25,6 +31,8 @@ options = {
fps = "60/1.001",
-- 指定合并重复弹幕的时间间隔的容差值,单位为秒。默认值: -1,表示禁用
merge_tolerance = -1,
-- 合并重复弹幕时是否强制合并类型和颜色不同的弹幕。默认值: false,表示仅合并类型和颜色相同的弹幕
merge_without_style = false,
-- 指定弹幕关联历史记录文件的路径,支持绝对路径和相对路径
history_path = "~~/danmaku-history.json",
open_search_danmaku_menu_key = "Ctrl+d",
+100 -38
View File
@@ -10,6 +10,33 @@ local function ass_escape(text)
:gsub("\n", "\\N")
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)
return str:gsub("&quot;", "\"")
:gsub("&apos;", "'")
@@ -154,22 +181,65 @@ local function merge_duplicate_danmaku(danmakus, threshold)
local groups = {}
for _, d in ipairs(danmakus) do
local tkey = tostring(d.type or "")
local ckey = tostring(d.color or "")
local tkey = options.merge_without_style and "any" or tostring(d.type or "")
local text = d.text or ""
groups[tkey] = groups[tkey] or {}
groups[tkey][ckey] = groups[tkey][ckey] or {}
groups[tkey][ckey][text] = groups[tkey][ckey][text] or {}
table.insert(groups[tkey][ckey][text], d)
groups[text] = groups[text] or {}
groups[text][tkey] = groups[text][tkey] or {}
table.insert(groups[text][tkey], 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
local merged = {}
local abs = math.abs
for _, bytype in pairs(groups) do
for _, bycolor in pairs(bytype) do
for _, group in pairs(bycolor) do
for _, group in ipairs(final_groups) do
table.sort(group, function(a, b) return a.time < b.time end)
local i = 1
@@ -210,8 +280,6 @@ local function merge_duplicate_danmaku(danmakus, threshold)
i = j
end
end
end
end
table.sort(merged, function(a, b) return a.time < b.time end)
return merged
@@ -259,8 +327,11 @@ local function limit_danmaku(danmakus, limit)
end
-- 解析 XML 弹幕
local function parse_xml_danmaku(xml_string)
function parse_xml_danmaku(xml_string)
local danmakus = {}
if not xml_string then
return danmakus
end
-- [^>]* 匹配其他 attributes
-- %f[^%s] 确保 p= 前面是空白字符
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
-- 解析 JSON 弹幕
local function parse_json_danmaku(json_string)
function parse_json_danmaku(json_string)
local danmakus = {}
if json_string:sub(1, 3) == "\239\187\191" then
json_string = json_string:sub(4)
@@ -469,10 +540,22 @@ end
-- 将弹幕转换为 XML 格式
function convert_danmaku_to_xml(danmaku_out)
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
local get_cached_delay = make_delay_lookup(source)
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
@@ -521,29 +604,7 @@ function convert_danmaku_to_ass_events(force)
local per_source_lists = {}
for url, source in pairs(DANMAKU.sources) do
if not source.blocked and source.data then
local segments = nil
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 get_cached_delay = make_delay_lookup(source)
local list = {}
for _, d in ipairs(source.data) do
@@ -711,6 +772,7 @@ function convert_danmaku_to_ass_events(force)
clean_text = clean_text,
pos = pos,
move = move,
layer = (style == "R2L") and 0 or 1,
source = d.source,
}
table.insert(ass_events, event)
+33 -13
View File
@@ -5,7 +5,8 @@ local unpack = unpack or table.unpack
local osd_width, osd_height, pause = 0, 0, true
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)
if not event.move then
@@ -51,7 +52,8 @@ function render(pos_arg)
end
if not pos then
overlay:remove()
overlay_low:remove()
overlay_high:remove()
return
end
@@ -67,7 +69,8 @@ function render(pos_arg)
fontsize = options.fontsize - ratio * 2
end
local ass_events = {}
local ass_events_low = {}
local ass_events_high = {}
local max_display = math.max(options.scrolltime, options.fixtime)
local window_start = pos - max_display
@@ -99,22 +102,36 @@ function render(pos_arg)
-- 构建 ASS 字符串
local ass_text = text and (ass_prefix .. text)
table.insert(ass_events, ass_text)
if ass_text then
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
overlay.res_x = width
overlay.res_y = height
overlay.data = table.concat(ass_events, '\n')
overlay:update()
-- 写入低层(滚动)和高层(顶/底)overlay,并设置 z 值以控制堆叠
overlay_low.res_x = width
overlay_low.res_y = height
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
local function time_pos_callback(_, time_pos)
if time_pos then
render(time_pos)
else
overlay:remove()
overlay_low:remove()
overlay_high:remove()
end
end
@@ -137,7 +154,7 @@ function render_danmaku(from_menu, no_osd)
if not no_osd then
show_loaded(true)
end
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", "on")
toggle_danmaku_switch("on")
show_danmaku_func()
else
show_message("")
@@ -179,7 +196,8 @@ function hide_danmaku_func()
stop_time_observer()
mp.set_property_bool(HAS_DANMAKU, false)
set_danmaku_visibility(false)
overlay:remove()
overlay_low:remove()
overlay_high:remove()
if filter_state("danmaku") then
mp.commandv("vf", "remove", "@danmaku")
end
@@ -235,7 +253,8 @@ end)
mp.add_hook("on_unload", 50, function()
COMMENTS, DELAY = nil, 0
stop_time_observer()
overlay:remove()
overlay_low:remove()
overlay_high:remove()
mp.set_property_native(DELAY_PROPERTY, 0)
if filter_state("danmaku") then
mp.commandv("vf", "remove", "@danmaku")
@@ -256,4 +275,5 @@ mp.add_hook("on_unload", 50, function()
end
DANMAKU = {sources = {}, count = 1}
mp.set_property_native(DANMAKU_COUNT, 0)
end)
+247 -10
View File
@@ -201,6 +201,18 @@ function is_protocol(path)
return type(path) == 'string' and (path:find('^%a[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= nil)
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)
return (hexstr:gsub('..', function (cc)
return string.char(tonumber(cc, 16))
@@ -214,6 +226,9 @@ end
function hex_to_int_color(hex_color)
-- 移除颜色代码中的'#'字符
hex_color = hex_color:sub(2) -- 只保留颜色代码部分
if not hex_color:match("^%x%x%x%x%x%x$") then
return 16777215
end
-- 提取R, G, B的十六进制值并转为整数
local r = tonumber(hex_color:sub(1, 2), 16)
@@ -226,14 +241,14 @@ function hex_to_int_color(hex_color)
return color_int
end
local function get_type_from_position(position)
if position == 0 then
return 1
end
if position == 1 then
return 4
end
return 5
function color_dist(c1, c2)
local r1 = math.floor(c1 / 65536) % 256
local g1 = math.floor(c1 / 256) % 256
local b1 = c1 % 256
local r2 = math.floor(c2 / 65536) % 256
local g2 = math.floor(c2 / 256) % 256
local b2 = c2 % 256
return math.sqrt((r1-r2)^2 + (g1-g2)^2 + (b1-b2)^2)
end
-- url编码转换
@@ -424,6 +439,42 @@ function contains_any(tab, val)
return false
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
function read_file(file_path)
local file = io.open(file_path, "r") -- 打开文件,"r"表示只读模式
@@ -727,10 +778,29 @@ function number_to_chinese(num)
return result
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 函数,用于取消异步命令
function call_cmd_async(args, callback)
async_running = true
-- 标记异步开始
mark_async_start()
local abort_signal = mp.command_native_async({
name = 'subprocess',
capture_stderr = true,
@@ -738,9 +808,14 @@ function call_cmd_async(args, callback)
playback_only = true,
args = args,
}, function(success, result, error)
-- 标记异步结束
mark_async_end()
if not success or not result or result.status ~= 0 then
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, {})
return
end
@@ -753,3 +828,165 @@ function call_cmd_async(args, callback)
mp.abort_async_command(abort_signal)
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
+118
View File
@@ -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
+246
View File
@@ -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
+290
View File
@@ -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
+146
View File
@@ -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
+157
View File
@@ -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
+323
View File
@@ -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