diff --git a/README.md b/README.md index 2e9f794..b55f4b4 100644 --- a/README.md +++ b/README.md @@ -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` 对照脚本源码更新 diff --git a/mpv.conf b/mpv.conf index a107d22..6441a34 100644 --- a/mpv.conf +++ b/mpv.conf @@ -1,6 +1,6 @@ # ===== 基础设置 ===== -vo=gpu-next # 视频输出驱动 +vo=gpu-next # 视频输出驱动 gpu-api=vulkan # 图形绘制接口 input-ime=no # 仅文本输入时激活 IME autofit-smaller=40%x30% # 窗口最小占屏比例 @@ -16,7 +16,7 @@ watch-later-options=start,vid,aid,sid save-watch-history=yes # 保存播放历史(内置 select 脚本可浏览) watch-history-path=~~/files/watch_history.jsonl reset-on-next-file=vid,aid,sid,secondary-sid,vf,af,loop-file,deinterlace,contrast,brightness,gamma,saturation,hue,video-zoom,video-rotate,video-pan-x,video-pan-y,panscan,speed,audio-delay,sub-pos,sub-scale,sub-delay,sub-speed,sub-visibility,secondary-sub-visibility -input-ipc-server=/tmp/mpvsocket # IPC 套接字 +input-ipc-server=/tmp/mpvsocket # IPC 套接字 directory-mode=ignore # 打开目录时忽略子目录 metadata-codepage=auto # 元数据编码自动检测 msg-level=all=info,auto_profiles=warn diff --git a/script-opts/trackselect.conf b/script-opts/trackselect.conf index 6c7afdd..abdb49b 100644 --- a/script-opts/trackselect.conf +++ b/script-opts/trackselect.conf @@ -11,7 +11,7 @@ force=no #如果 mpv 无法做到这一点,请尝试重新选择最后一个轨道,例如当信息更改时,默认 no smart_keep=no ## 指定需忽略的特殊协议 -special_protocols=["://", "^magnet:"] +#special_protocols=["://", "^magnet:"] #视频轨道筛选项设置 #preferred_video_lang= diff --git a/script-opts/uosc_danmaku.conf b/script-opts/uosc_danmaku.conf index 875912f..142e147 100644 --- a/script-opts/uosc_danmaku.conf +++ b/script-opts/uosc_danmaku.conf @@ -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= - diff --git a/scripts/chapter-make-read.lua b/scripts/chapter-make-read.lua index a0cc91c..4355091 100644 --- a/scripts/chapter-make-read.lua +++ b/scripts/chapter-make-read.lua @@ -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 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,9 +112,13 @@ 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 diff --git a/scripts/fix-avsync.lua b/scripts/fix-avsync.lua index cdc8d63..3e4236b 100644 --- a/scripts/fix-avsync.lua +++ b/scripts/fix-avsync.lua @@ -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) \ No newline at end of file diff --git a/scripts/hdr-mode.lua b/scripts/hdr-mode.lua index 54c3795..5c3c890 100644 --- a/scripts/hdr-mode.lua +++ b/scripts/hdr-mode.lua @@ -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() diff --git a/scripts/mpv-cropscreen.lua b/scripts/mpv-cropscreen.lua new file mode 100644 index 0000000..f50a30b --- /dev/null +++ b/scripts/mpv-cropscreen.lua @@ -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) \ No newline at end of file diff --git a/scripts/mpv-torrserver.lua b/scripts/mpv-torrserver.lua new file mode 100644 index 0000000..ea913a3 --- /dev/null +++ b/scripts/mpv-torrserver.lua @@ -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) \ No newline at end of file diff --git a/scripts/pip.lua b/scripts/pip.lua new file mode 100644 index 0000000..3c70230 --- /dev/null +++ b/scripts/pip.lua @@ -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) \ No newline at end of file diff --git a/scripts/sub-assrt.lua b/scripts/sub-assrt.lua index 59e7dda..c7af781 100644 --- a/scripts/sub-assrt.lua +++ b/scripts/sub-assrt.lua @@ -54,12 +54,13 @@ 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) diff --git a/scripts/trackselect.lua b/scripts/trackselect.lua index e9a0e92..0864006 100644 --- a/scripts/trackselect.lua +++ b/scripts/trackselect.lua @@ -1,10 +1,13 @@ --- trackselect.lua --- https://github.com/po5/trackselect --- 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 +--[[ + 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 - return true +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 - 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 (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 (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 preferred_lang ~= "" then + lang = preferred(track, preferred_lang, "lang", "lang_score") + end + + 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 options["preferred_" .. track.type .. "_lang"] ~= "" then - lang = preferred(track, options["preferred_" .. track.type .. "_lang"], "lang", "lang_score") + 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 - 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 + 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 \ No newline at end of file +mp.add_hook("on_unload", 50, function() + trackselect_ran = false + mp.unobserve_property(selected_tracks) +end) \ No newline at end of file diff --git a/scripts/uosc/elements/Button.lua b/scripts/uosc/elements/Button.lua index 422f024..2860d9e 100644 --- a/scripts/uosc/elements/Button.lua +++ b/scripts/uosc/elements/Button.lua @@ -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 diff --git a/scripts/uosc/elements/Menu.lua b/scripts/uosc/elements/Menu.lua index 137979d..497daad 100644 --- a/scripts/uosc/elements/Menu.lua +++ b/scripts/uosc/elements/Menu.lua @@ -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,14 +1458,16 @@ 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 - menu.selected_index = index - if not is_selected then - is_selected = true - request_render() + 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 @@ -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 diff --git a/scripts/uosc/elements/TopBar.lua b/scripts/uosc/elements/TopBar.lua index f8ecc6f..2f9fc40 100644 --- a/scripts/uosc/elements/TopBar.lua +++ b/scripts/uosc/elements/TopBar.lua @@ -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 diff --git a/scripts/uosc/lib/cursor.lua b/scripts/uosc/lib/cursor.lua index c7a5cb5..f68819f 100644 --- a/scripts/uosc/lib/cursor.lua +++ b/scripts/uosc/lib/cursor.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc/lib/utils.lua b/scripts/uosc/lib/utils.lua index 0e2f2a8..52cfe35 100644 --- a/scripts/uosc/lib/utils.lua +++ b/scripts/uosc/lib/utils.lua @@ -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] diff --git a/scripts/uosc_danmaku/README.md b/scripts/uosc_danmaku/README.md index de50f5a..299651f 100644 --- a/scripts/uosc_danmaku/README.md +++ b/scripts/uosc_danmaku/README.md @@ -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 @@ +## 目录 + +- [项目简介](#项目简介) + - [主要功能](#主要功能) +- [安装](#安装) + - [下载](#下载) +- [基本配置](#基本配置) + - [uosc控件配置](#uosc控件配置) + - [绑定快捷键(可选)](#绑定快捷键可选) +- [拓展功能(可选)](#拓展功能可选) + - [从弹幕源向当前弹幕添加新弹幕内容(从网络url或本地添加弹幕)](#从弹幕源向当前弹幕添加新弹幕内容可选) + - [弹幕源延迟设置](#弹幕源延迟设置可选) + - [实时修改弹幕样式](#实时修改弹幕样式可选) + - [弹幕设置总菜单](#弹幕设置总菜单可选) + - [设置弹幕延迟(可选)](#设置弹幕延迟可选) + - [保存当前视频弹幕(可选)](#保存当前视频弹幕可选) + - [清空当前视频关联的弹幕源(可选)](#清空当前视频关联的弹幕源可选) + - [检查脚本更新(可选)](#检查脚本更新可选) +- [可配置选项(可选)](#可配置选项可选) + - [弹幕加载相关](#弹幕加载相关) + - [弹幕显示相关](#弹幕显示相关) + - [弹幕解析服务相关](#弹幕解析服务相关) + - [插件配置相关](#插件配置相关) + - [自定义弹幕样式相关配置](#自定义弹幕样式相关配置) +- [插件自定义属性](#插件自定义属性) +- [常见问题](#常见问题) +- [特别感谢](#特别感谢) +- [相关项目](#相关项目) + ## 安装 ### 下载 一般的mpv配置目录结构大致如下 +> [!NOTE] +> Windows 系统上等价全局配置路径`%APPDATA%/mpv/`,也可使用 mpv.exe 所在目录的 portable_config 文件夹(便携配置路径) + ``` ~/.config/mpv ├── fonts @@ -301,7 +336,7 @@ key script-message danmaku-delay ${=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 ``` - +如需保存到指定文件夹,请提前创建目标文件夹,然后配置 `save_danmaku_path` 和 `save_danmaku_path_mode`。插件不会自动创建目录。 ---- +保存本地媒体到指定文件夹时,文件名会包含视频父目录名,用于减少不同目录下同名视频的弹幕文件冲突。例如 `动画/01.mkv` 会保存为类似 `动画_01.xml`。网络媒体保存到指定文件夹时仍使用媒体标题作为文件名。 -
- +`save_danmaku_path_mode` 可选值如下: -~~add_from_source~~ +- `local`:默认值,仅本地媒体保存到 `save_danmaku_path`,网络媒体仍不保存 +- `url`:仅网络媒体保存到 `save_danmaku_path`,本地媒体仍保存到视频同目录 +- `all`:本地媒体和网络媒体都保存到 `save_danmaku_path` -> ~~开关记录通过 从弹幕源向当前弹幕添加新弹幕内容 关联过的弹幕源并自动加载(已废除)~~ - - - -### 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 ```
@@ -613,6 +643,35 @@ merge_tolerance=1 --- +
+ +merge_without_style + +> 决定在合并重复弹幕时是否忽略类型和颜色差异 + + + +### merge_without_style + +#### 功能说明 + +配合 `merge_tolerance` 使用。默认值: `no`(关闭) + +当开启此选项时,即使属于不同位置类型(如顶部、底部、滚动)或不同颜色(无论色差多大),只要弹幕文本内容相同并且在 `merge_tolerance` 指定的时间容差范围内,就会被强制合并成一条弹幕。 +关闭时,仅当弹幕内容相同、位置类型相同、且颜色肉眼不可区分(色差极小)时,才会进行弹幕合并。 + +#### 使用方法 + +想要使用此选项,请在mpv配置文件夹下的 `script-opts`中创建 `uosc_danmaku.conf`文件并自定义如下内容: + +``` +merge_without_style=yes +``` + +
+ +--- +
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 ```
@@ -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) \ No newline at end of file +- ~~[Loukyuu1120/uosc_danmaku](https://github.com/Loukyuu1120/uosc_danmaku) 本项目的fork版本,实现了自定义多个 api_servers 与 弹幕来源选择菜单 功能,具体讨论请参阅 [#282](https://github.com/Tony15246/uosc_danmaku/issues/282)~~ 相关功能主仓库已实现 \ No newline at end of file diff --git a/scripts/uosc_danmaku/apis/dandanplay.lua b/scripts/uosc_danmaku/apis/dandanplay.lua index fa8f42e..4ede78d 100644 --- a/scripts/uosc_danmaku/apis/dandanplay.lua +++ b/scripts/uosc_danmaku/apis/dandanplay.lua @@ -21,47 +21,114 @@ 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 url = options.fallback_server .. "/?ac=dm&url=" .. query - msg.verbose("尝试获取弹幕:" .. url) + local function do_fallback() + if options.fallback_server == "" then return end + local url = options.fallback_server .. "/?ac=dm&url=" .. query + msg.verbose("尝试获取弹幕:" .. url) - local args = make_danmaku_request_args("GET", url) - if not args then return end + local args = make_danmaku_request_args("GET", url) + if not args then return end - fetch_danmaku_data(args, function(data) - if not data or not data["comments"] or data["count"] <= 1 then - msg.info("备用服务器无数据或返回格式不正确") - show_message("备用服务器无数据或返回格式不正确", 3) - return - end + 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 - save_danmaku_data(data["comments"], query, "user_custom") - load_danmaku(true) - end) + if not data or not data["comments"] or data["count"] <= 1 then + msg.info("备用服务器无数据或返回格式不正确") + show_message("备用服务器无数据或返回格式不正确", 3) + return + end + + save_danmaku_data(data["comments"], query, "user_custom") + load_danmaku(true) + end) + end + + 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 -- 返回弹幕请求参数 @@ -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) + 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 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(json) + 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) - data = normalize_danmaku_response(data) + if data ~= nil then + data = normalize_danmaku_response(data) + else + local danmaku = parse_xml_danmaku(json) + if #danmaku > 0 then + data = {} + data["xml"] = danmaku + end + end callback(data) 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 api(url)+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) diff --git a/scripts/uosc_danmaku/apis/extra.lua b/scripts/uosc_danmaku/apis/extra.lua index a124d0b..60cd4f6 100644 --- a/scripts/uosc_danmaku/apis/extra.lua +++ b/scripts/uosc_danmaku/apis/extra.lua @@ -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,59 +249,57 @@ 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 - episodes = get_episodes_v2(cat, id, site) + local cid = tostring(id) + local cached = cached_series_playlinks[cid] + if cached and cached.seriesPlaylinks and cached.seriesSite and tostring(cached.seriesSite) == tostring(site) then + local rows = build_episode_rows(cached.seriesPlaylinks, cached.playlink) + if rows then + episode_rows = rows + end + end end - -- 统一构建 episode_rows:优先使用 episodesv2 返回的数据,否则使用 v1/detail - local episode_rows = nil - if episodes then - episode_rows = {} - for _, ep in ipairs(episodes) do - table.insert(episode_rows, { index = tostring(ep.index), url = ep.url }) - end - else - if not number and cat ~= 0 then - number = get_number(cat, id, site) - end - if not number or cat == 0 then - local message = "无结果" - if uosc_available and not episodenum then - update_menu_uosc(menu_type, menu_title, message, footnote) - else - show_message(message, 3) - end - msg.verbose("无结果") - return + -- 若未命中缓存,则继续使用 episodesv2/v1 的原有流程 + if not episode_rows then + if cat == 2 or cat == 4 then + episodes = get_episodes_v2(cat, id, site) 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 - local message = "无结果" - if uosc_available and not episodenum then - update_menu_uosc(menu_type, menu_title, message, footnote) - else - show_message(message, 3) - end - msg.verbose("无结果") - return - end - - local result = utils.parse_json(res.stdout) - if result and result.data and result.data.allepidetail and result.data.allepidetail[site] then + -- 统一构建 episode_rows:优先使用 episodesv2 返回的数据,否则使用 v1/detail + if episodes then episode_rows = {} - for _, it in ipairs(result.data.allepidetail[site]) do - table.insert(episode_rows, { index = tostring(it.playlink_num), url = it.url }) + for _, ep in ipairs(episodes) do + table.insert(episode_rows, { index = tostring(ep.index), url = ep.url }) + end + else + if not number and cat ~= 0 then + number = get_number(cat, id, site) + end + if not number or cat == 0 then + local message = "无结果" + if uosc_available and not episodenum then + update_menu_uosc(menu_type, menu_title, message, footnote) + else + show_message(message, 3) + end + msg.verbose("无结果") + return + end + + episode_rows = get_episodes_v1(cat, id, site, number) + if not episode_rows or #episode_rows == 0 then + local message = "无结果" + if uosc_available and not episodenum then + update_menu_uosc(menu_type, menu_title, message, footnote) + else + show_message(message, 3) + end + msg.verbose("无结果") + return end end end @@ -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 diff --git a/scripts/uosc_danmaku/main.lua b/scripts/uosc_danmaku/main.lua index 935af72..378dfdf 100644 --- a/scripts/uosc_danmaku/main.lua +++ b/scripts/uosc_danmaku/main.lua @@ -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,10 +254,12 @@ local function set_danmaku_delay(dly, time, specific_source) end end - if dly == 0 then - DELAY = 0 - else - DELAY = DELAY + dly + if not specific_source then + if dly == 0 then + DELAY = 0 + else + DELAY = DELAY + dly + end end if ENABLED and COMMENTS ~= nil then @@ -310,8 +279,14 @@ local function set_danmaku_delay(dly, time, specific_source) rebuild_convert_timer = nil end) - show_message('设置弹幕延迟: ' .. string.format("%.1f", DELAY + 1e-10) .. ' s') - mp.set_property_native(DELAY_PROPERTY, DELAY) + 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() @@ -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) diff --git a/scripts/uosc_danmaku/modules/aes.lua b/scripts/uosc_danmaku/modules/aes.lua index 3dd3d3b..bd83c32 100644 --- a/scripts/uosc_danmaku/modules/aes.lua +++ b/scripts/uosc_danmaku/modules/aes.lua @@ -1,491 +1,491 @@ --- modified from https://github.com/idiomic/Lua_AES ---[[ - Copyright 2019 Tyler Richard Hoyer - Copyright 2025 dyphire - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -]] - -local unpack = unpack or table.unpack - -local GF8x2 = { - [0]=0x00,0x02,0x04,0x06,0x08,0x0a,0x0c,0x0e,0x10,0x12,0x14,0x16,0x18,0x1a,0x1c,0x1e, - 0x20,0x22,0x24,0x26,0x28,0x2a,0x2c,0x2e,0x30,0x32,0x34,0x36,0x38,0x3a,0x3c,0x3e, - 0x40,0x42,0x44,0x46,0x48,0x4a,0x4c,0x4e,0x50,0x52,0x54,0x56,0x58,0x5a,0x5c,0x5e, - 0x60,0x62,0x64,0x66,0x68,0x6a,0x6c,0x6e,0x70,0x72,0x74,0x76,0x78,0x7a,0x7c,0x7e, - 0x80,0x82,0x84,0x86,0x88,0x8a,0x8c,0x8e,0x90,0x92,0x94,0x96,0x98,0x9a,0x9c,0x9e, - 0xa0,0xa2,0xa4,0xa6,0xa8,0xaa,0xac,0xae,0xb0,0xb2,0xb4,0xb6,0xb8,0xba,0xbc,0xbe, - 0xc0,0xc2,0xc4,0xc6,0xc8,0xca,0xcc,0xce,0xd0,0xd2,0xd4,0xd6,0xd8,0xda,0xdc,0xde, - 0xe0,0xe2,0xe4,0xe6,0xe8,0xea,0xec,0xee,0xf0,0xf2,0xf4,0xf6,0xf8,0xfa,0xfc,0xfe, - 0x1b,0x19,0x1f,0x1d,0x13,0x11,0x17,0x15,0x0b,0x09,0x0f,0x0d,0x03,0x01,0x07,0x05, - 0x3b,0x39,0x3f,0x3d,0x33,0x31,0x37,0x35,0x2b,0x29,0x2f,0x2d,0x23,0x21,0x27,0x25, - 0x5b,0x59,0x5f,0x5d,0x53,0x51,0x57,0x55,0x4b,0x49,0x4f,0x4d,0x43,0x41,0x47,0x45, - 0x7b,0x79,0x7f,0x7d,0x73,0x71,0x77,0x75,0x6b,0x69,0x6f,0x6d,0x63,0x61,0x67,0x65, - 0x9b,0x99,0x9f,0x9d,0x93,0x91,0x97,0x95,0x8b,0x89,0x8f,0x8d,0x83,0x81,0x87,0x85, - 0xbb,0xb9,0xbf,0xbd,0xb3,0xb1,0xb7,0xb5,0xab,0xa9,0xaf,0xad,0xa3,0xa1,0xa7,0xa5, - 0xdb,0xd9,0xdf,0xdd,0xd3,0xd1,0xd7,0xd5,0xcb,0xc9,0xcf,0xcd,0xc3,0xc1,0xc7,0xc5, - 0xfb,0xf9,0xff,0xfd,0xf3,0xf1,0xf7,0xf5,0xeb,0xe9,0xef,0xed,0xe3,0xe1,0xe7,0xe5 - } - -local GF8x3 = { - [0]=0x00,0x03,0x06,0x05,0x0c,0x0f,0x0a,0x09,0x18,0x1b,0x1e,0x1d,0x14,0x17,0x12,0x11, - 0x30,0x33,0x36,0x35,0x3c,0x3f,0x3a,0x39,0x28,0x2b,0x2e,0x2d,0x24,0x27,0x22,0x21, - 0x60,0x63,0x66,0x65,0x6c,0x6f,0x6a,0x69,0x78,0x7b,0x7e,0x7d,0x74,0x77,0x72,0x71, - 0x50,0x53,0x56,0x55,0x5c,0x5f,0x5a,0x59,0x48,0x4b,0x4e,0x4d,0x44,0x47,0x42,0x41, - 0xc0,0xc3,0xc6,0xc5,0xcc,0xcf,0xca,0xc9,0xd8,0xdb,0xde,0xdd,0xd4,0xd7,0xd2,0xd1, - 0xf0,0xf3,0xf6,0xf5,0xfc,0xff,0xfa,0xf9,0xe8,0xeb,0xee,0xed,0xe4,0xe7,0xe2,0xe1, - 0xa0,0xa3,0xa6,0xa5,0xac,0xaf,0xaa,0xa9,0xb8,0xbb,0xbe,0xbd,0xb4,0xb7,0xb2,0xb1, - 0x90,0x93,0x96,0x95,0x9c,0x9f,0x9a,0x99,0x88,0x8b,0x8e,0x8d,0x84,0x87,0x82,0x81, - 0x9b,0x98,0x9d,0x9e,0x97,0x94,0x91,0x92,0x83,0x80,0x85,0x86,0x8f,0x8c,0x89,0x8a, - 0xab,0xa8,0xad,0xae,0xa7,0xa4,0xa1,0xa2,0xb3,0xb0,0xb5,0xb6,0xbf,0xbc,0xb9,0xba, - 0xfb,0xf8,0xfd,0xfe,0xf7,0xf4,0xf1,0xf2,0xe3,0xe0,0xe5,0xe6,0xef,0xec,0xe9,0xea, - 0xcb,0xc8,0xcd,0xce,0xc7,0xc4,0xc1,0xc2,0xd3,0xd0,0xd5,0xd6,0xdf,0xdc,0xd9,0xda, - 0x5b,0x58,0x5d,0x5e,0x57,0x54,0x51,0x52,0x43,0x40,0x45,0x46,0x4f,0x4c,0x49,0x4a, - 0x6b,0x68,0x6d,0x6e,0x67,0x64,0x61,0x62,0x73,0x70,0x75,0x76,0x7f,0x7c,0x79,0x7a, - 0x3b,0x38,0x3d,0x3e,0x37,0x34,0x31,0x32,0x23,0x20,0x25,0x26,0x2f,0x2c,0x29,0x2a, - 0x0b,0x08,0x0d,0x0e,0x07,0x04,0x01,0x02,0x13,0x10,0x15,0x16,0x1f,0x1c,0x19,0x1a -} - -local GF8x9 = { - [0]=0x00,0x09,0x12,0x1b,0x24,0x2d,0x36,0x3f,0x48,0x41,0x5a,0x53,0x6c,0x65,0x7e,0x77, - 0x90,0x99,0x82,0x8b,0xb4,0xbd,0xa6,0xaf,0xd8,0xd1,0xca,0xc3,0xfc,0xf5,0xee,0xe7, - 0x3b,0x32,0x29,0x20,0x1f,0x16,0x0d,0x04,0x73,0x7a,0x61,0x68,0x57,0x5e,0x45,0x4c, - 0xab,0xa2,0xb9,0xb0,0x8f,0x86,0x9d,0x94,0xe3,0xea,0xf1,0xf8,0xc7,0xce,0xd5,0xdc, - 0x76,0x7f,0x64,0x6d,0x52,0x5b,0x40,0x49,0x3e,0x37,0x2c,0x25,0x1a,0x13,0x08,0x01, - 0xe6,0xef,0xf4,0xfd,0xc2,0xcb,0xd0,0xd9,0xae,0xa7,0xbc,0xb5,0x8a,0x83,0x98,0x91, - 0x4d,0x44,0x5f,0x56,0x69,0x60,0x7b,0x72,0x05,0x0c,0x17,0x1e,0x21,0x28,0x33,0x3a, - 0xdd,0xd4,0xcf,0xc6,0xf9,0xf0,0xeb,0xe2,0x95,0x9c,0x87,0x8e,0xb1,0xb8,0xa3,0xaa, - 0xec,0xe5,0xfe,0xf7,0xc8,0xc1,0xda,0xd3,0xa4,0xad,0xb6,0xbf,0x80,0x89,0x92,0x9b, - 0x7c,0x75,0x6e,0x67,0x58,0x51,0x4a,0x43,0x34,0x3d,0x26,0x2f,0x10,0x19,0x02,0x0b, - 0xd7,0xde,0xc5,0xcc,0xf3,0xfa,0xe1,0xe8,0x9f,0x96,0x8d,0x84,0xbb,0xb2,0xa9,0xa0, - 0x47,0x4e,0x55,0x5c,0x63,0x6a,0x71,0x78,0x0f,0x06,0x1d,0x14,0x2b,0x22,0x39,0x30, - 0x9a,0x93,0x88,0x81,0xbe,0xb7,0xac,0xa5,0xd2,0xdb,0xc0,0xc9,0xf6,0xff,0xe4,0xed, - 0x0a,0x03,0x18,0x11,0x2e,0x27,0x3c,0x35,0x42,0x4b,0x50,0x59,0x66,0x6f,0x74,0x7d, - 0xa1,0xa8,0xb3,0xba,0x85,0x8c,0x97,0x9e,0xe9,0xe0,0xfb,0xf2,0xcd,0xc4,0xdf,0xd6, - 0x31,0x38,0x23,0x2a,0x15,0x1c,0x07,0x0e,0x79,0x70,0x6b,0x62,0x5d,0x54,0x4f,0x46 -} - -local GF8x11 = { - [0]=0x00,0x0b,0x16,0x1d,0x2c,0x27,0x3a,0x31,0x58,0x53,0x4e,0x45,0x74,0x7f,0x62,0x69, - 0xb0,0xbb,0xa6,0xad,0x9c,0x97,0x8a,0x81,0xe8,0xe3,0xfe,0xf5,0xc4,0xcf,0xd2,0xd9, - 0x7b,0x70,0x6d,0x66,0x57,0x5c,0x41,0x4a,0x23,0x28,0x35,0x3e,0x0f,0x04,0x19,0x12, - 0xcb,0xc0,0xdd,0xd6,0xe7,0xec,0xf1,0xfa,0x93,0x98,0x85,0x8e,0xbf,0xb4,0xa9,0xa2, - 0xf6,0xfd,0xe0,0xeb,0xda,0xd1,0xcc,0xc7,0xae,0xa5,0xb8,0xb3,0x82,0x89,0x94,0x9f, - 0x46,0x4d,0x50,0x5b,0x6a,0x61,0x7c,0x77,0x1e,0x15,0x08,0x03,0x32,0x39,0x24,0x2f, - 0x8d,0x86,0x9b,0x90,0xa1,0xaa,0xb7,0xbc,0xd5,0xde,0xc3,0xc8,0xf9,0xf2,0xef,0xe4, - 0x3d,0x36,0x2b,0x20,0x11,0x1a,0x07,0x0c,0x65,0x6e,0x73,0x78,0x49,0x42,0x5f,0x54, - 0xf7,0xfc,0xe1,0xea,0xdb,0xd0,0xcd,0xc6,0xaf,0xa4,0xb9,0xb2,0x83,0x88,0x95,0x9e, - 0x47,0x4c,0x51,0x5a,0x6b,0x60,0x7d,0x76,0x1f,0x14,0x09,0x02,0x33,0x38,0x25,0x2e, - 0x8c,0x87,0x9a,0x91,0xa0,0xab,0xb6,0xbd,0xd4,0xdf,0xc2,0xc9,0xf8,0xf3,0xee,0xe5, - 0x3c,0x37,0x2a,0x21,0x10,0x1b,0x06,0x0d,0x64,0x6f,0x72,0x79,0x48,0x43,0x5e,0x55, - 0x01,0x0a,0x17,0x1c,0x2d,0x26,0x3b,0x30,0x59,0x52,0x4f,0x44,0x75,0x7e,0x63,0x68, - 0xb1,0xba,0xa7,0xac,0x9d,0x96,0x8b,0x80,0xe9,0xe2,0xff,0xf4,0xc5,0xce,0xd3,0xd8, - 0x7a,0x71,0x6c,0x67,0x56,0x5d,0x40,0x4b,0x22,0x29,0x34,0x3f,0x0e,0x05,0x18,0x13, - 0xca,0xc1,0xdc,0xd7,0xe6,0xed,0xf0,0xfb,0x92,0x99,0x84,0x8f,0xbe,0xb5,0xa8,0xa3 -} - -local GF8x13 = { - [0]=0x00,0x0d,0x1a,0x17,0x34,0x39,0x2e,0x23,0x68,0x65,0x72,0x7f,0x5c,0x51,0x46,0x4b, - 0xd0,0xdd,0xca,0xc7,0xe4,0xe9,0xfe,0xf3,0xb8,0xb5,0xa2,0xaf,0x8c,0x81,0x96,0x9b, - 0xbb,0xb6,0xa1,0xac,0x8f,0x82,0x95,0x98,0xd3,0xde,0xc9,0xc4,0xe7,0xea,0xfd,0xf0, - 0x6b,0x66,0x71,0x7c,0x5f,0x52,0x45,0x48,0x03,0x0e,0x19,0x14,0x37,0x3a,0x2d,0x20, - 0x6d,0x60,0x77,0x7a,0x59,0x54,0x43,0x4e,0x05,0x08,0x1f,0x12,0x31,0x3c,0x2b,0x26, - 0xbd,0xb0,0xa7,0xaa,0x89,0x84,0x93,0x9e,0xd5,0xd8,0xcf,0xc2,0xe1,0xec,0xfb,0xf6, - 0xd6,0xdb,0xcc,0xc1,0xe2,0xef,0xf8,0xf5,0xbe,0xb3,0xa4,0xa9,0x8a,0x87,0x90,0x9d, - 0x06,0x0b,0x1c,0x11,0x32,0x3f,0x28,0x25,0x6e,0x63,0x74,0x79,0x5a,0x57,0x40,0x4d, - 0xda,0xd7,0xc0,0xcd,0xee,0xe3,0xf4,0xf9,0xb2,0xbf,0xa8,0xa5,0x86,0x8b,0x9c,0x91, - 0x0a,0x07,0x10,0x1d,0x3e,0x33,0x24,0x29,0x62,0x6f,0x78,0x75,0x56,0x5b,0x4c,0x41, - 0x61,0x6c,0x7b,0x76,0x55,0x58,0x4f,0x42,0x09,0x04,0x13,0x1e,0x3d,0x30,0x27,0x2a, - 0xb1,0xbc,0xab,0xa6,0x85,0x88,0x9f,0x92,0xd9,0xd4,0xc3,0xce,0xed,0xe0,0xf7,0xfa, - 0xb7,0xba,0xad,0xa0,0x83,0x8e,0x99,0x94,0xdf,0xd2,0xc5,0xc8,0xeb,0xe6,0xf1,0xfc, - 0x67,0x6a,0x7d,0x70,0x53,0x5e,0x49,0x44,0x0f,0x02,0x15,0x18,0x3b,0x36,0x21,0x2c, - 0x0c,0x01,0x16,0x1b,0x38,0x35,0x22,0x2f,0x64,0x69,0x7e,0x73,0x50,0x5d,0x4a,0x47, - 0xdc,0xd1,0xc6,0xcb,0xe8,0xe5,0xf2,0xff,0xb4,0xb9,0xae,0xa3,0x80,0x8d,0x9a,0x97 -} - -local GF8x14 = { - [0]=0x00,0x0e,0x1c,0x12,0x38,0x36,0x24,0x2a,0x70,0x7e,0x6c,0x62,0x48,0x46,0x54,0x5a, - 0xe0,0xee,0xfc,0xf2,0xd8,0xd6,0xc4,0xca,0x90,0x9e,0x8c,0x82,0xa8,0xa6,0xb4,0xba, - 0xdb,0xd5,0xc7,0xc9,0xe3,0xed,0xff,0xf1,0xab,0xa5,0xb7,0xb9,0x93,0x9d,0x8f,0x81, - 0x3b,0x35,0x27,0x29,0x03,0x0d,0x1f,0x11,0x4b,0x45,0x57,0x59,0x73,0x7d,0x6f,0x61, - 0xad,0xa3,0xb1,0xbf,0x95,0x9b,0x89,0x87,0xdd,0xd3,0xc1,0xcf,0xe5,0xeb,0xf9,0xf7, - 0x4d,0x43,0x51,0x5f,0x75,0x7b,0x69,0x67,0x3d,0x33,0x21,0x2f,0x05,0x0b,0x19,0x17, - 0x76,0x78,0x6a,0x64,0x4e,0x40,0x52,0x5c,0x06,0x08,0x1a,0x14,0x3e,0x30,0x22,0x2c, - 0x96,0x98,0x8a,0x84,0xae,0xa0,0xb2,0xbc,0xe6,0xe8,0xfa,0xf4,0xde,0xd0,0xc2,0xcc, - 0x41,0x4f,0x5d,0x53,0x79,0x77,0x65,0x6b,0x31,0x3f,0x2d,0x23,0x09,0x07,0x15,0x1b, - 0xa1,0xaf,0xbd,0xb3,0x99,0x97,0x85,0x8b,0xd1,0xdf,0xcd,0xc3,0xe9,0xe7,0xf5,0xfb, - 0x9a,0x94,0x86,0x88,0xa2,0xac,0xbe,0xb0,0xea,0xe4,0xf6,0xf8,0xd2,0xdc,0xce,0xc0, - 0x7a,0x74,0x66,0x68,0x42,0x4c,0x5e,0x50,0x0a,0x04,0x16,0x18,0x32,0x3c,0x2e,0x20, - 0xec,0xe2,0xf0,0xfe,0xd4,0xda,0xc8,0xc6,0x9c,0x92,0x80,0x8e,0xa4,0xaa,0xb8,0xb6, - 0x0c,0x02,0x10,0x1e,0x34,0x3a,0x28,0x26,0x7c,0x72,0x60,0x6e,0x44,0x4a,0x58,0x56, - 0x37,0x39,0x2b,0x25,0x0f,0x01,0x13,0x1d,0x47,0x49,0x5b,0x55,0x7f,0x71,0x63,0x6d, - 0xd7,0xd9,0xcb,0xc5,0xef,0xe1,0xf3,0xfd,0xa7,0xa9,0xbb,0xb5,0x9f,0x91,0x83,0x8d -} - -local s = { - [0]=0x63,0x7C,0x77,0x7B,0xF2,0x6B,0x6F,0xC5,0x30,0x01,0x67,0x2B,0xFE,0xD7,0xAB,0x76, - 0xCA,0x82,0xC9,0x7D,0xFA,0x59,0x47,0xF0,0xAD,0xD4,0xA2,0xAF,0x9C,0xA4,0x72,0xC0, - 0xB7,0xFD,0x93,0x26,0x36,0x3F,0xF7,0xCC,0x34,0xA5,0xE5,0xF1,0x71,0xD8,0x31,0x15, - 0x04,0xC7,0x23,0xC3,0x18,0x96,0x05,0x9A,0x07,0x12,0x80,0xE2,0xEB,0x27,0xB2,0x75, - 0x09,0x83,0x2C,0x1A,0x1B,0x6E,0x5A,0xA0,0x52,0x3B,0xD6,0xB3,0x29,0xE3,0x2F,0x84, - 0x53,0xD1,0x00,0xED,0x20,0xFC,0xB1,0x5B,0x6A,0xCB,0xBE,0x39,0x4A,0x4C,0x58,0xCF, - 0xD0,0xEF,0xAA,0xFB,0x43,0x4D,0x33,0x85,0x45,0xF9,0x02,0x7F,0x50,0x3C,0x9F,0xA8, - 0x51,0xA3,0x40,0x8F,0x92,0x9D,0x38,0xF5,0xBC,0xB6,0xDA,0x21,0x10,0xFF,0xF3,0xD2, - 0xCD,0x0C,0x13,0xEC,0x5F,0x97,0x44,0x17,0xC4,0xA7,0x7E,0x3D,0x64,0x5D,0x19,0x73, - 0x60,0x81,0x4F,0xDC,0x22,0x2A,0x90,0x88,0x46,0xEE,0xB8,0x14,0xDE,0x5E,0x0B,0xDB, - 0xE0,0x32,0x3A,0x0A,0x49,0x06,0x24,0x5C,0xC2,0xD3,0xAC,0x62,0x91,0x95,0xE4,0x79, - 0xE7,0xC8,0x37,0x6D,0x8D,0xD5,0x4E,0xA9,0x6C,0x56,0xF4,0xEA,0x65,0x7A,0xAE,0x08, - 0xBA,0x78,0x25,0x2E,0x1C,0xA6,0xB4,0xC6,0xE8,0xDD,0x74,0x1F,0x4B,0xBD,0x8B,0x8A, - 0x70,0x3E,0xB5,0x66,0x48,0x03,0xF6,0x0E,0x61,0x35,0x57,0xB9,0x86,0xC1,0x1D,0x9E, - 0xE1,0xF8,0x98,0x11,0x69,0xD9,0x8E,0x94,0x9B,0x1E,0x87,0xE9,0xCE,0x55,0x28,0xDF, - 0x8C,0xA1,0x89,0x0D,0xBF,0xE6,0x42,0x68,0x41,0x99,0x2D,0x0F,0xB0,0x54,0xBB,0x16 -} - -local si = { - [0]=0x52,0x09,0x6A,0xD5,0x30,0x36,0xA5,0x38,0xBF,0x40,0xA3,0x9E,0x81,0xF3,0xD7,0xFB, - 0x7C,0xE3,0x39,0x82,0x9B,0x2F,0xFF,0x87,0x34,0x8E,0x43,0x44,0xC4,0xDE,0xE9,0xCB, - 0x54,0x7B,0x94,0x32,0xA6,0xC2,0x23,0x3D,0xEE,0x4C,0x95,0x0B,0x42,0xFA,0xC3,0x4E, - 0x08,0x2E,0xA1,0x66,0x28,0xD9,0x24,0xB2,0x76,0x5B,0xA2,0x49,0x6D,0x8B,0xD1,0x25, - 0x72,0xF8,0xF6,0x64,0x86,0x68,0x98,0x16,0xD4,0xA4,0x5C,0xCC,0x5D,0x65,0xB6,0x92, - 0x6C,0x70,0x48,0x50,0xFD,0xED,0xB9,0xDA,0x5E,0x15,0x46,0x57,0xA7,0x8D,0x9D,0x84, - 0x90,0xD8,0xAB,0x00,0x8C,0xBC,0xD3,0x0A,0xF7,0xE4,0x58,0x05,0xB8,0xB3,0x45,0x06, - 0xD0,0x2C,0x1E,0x8F,0xCA,0x3F,0x0F,0x02,0xC1,0xAF,0xBD,0x03,0x01,0x13,0x8A,0x6B, - 0x3A,0x91,0x11,0x41,0x4F,0x67,0xDC,0xEA,0x97,0xF2,0xCF,0xCE,0xF0,0xB4,0xE6,0x73, - 0x96,0xAC,0x74,0x22,0xE7,0xAD,0x35,0x85,0xE2,0xF9,0x37,0xE8,0x1C,0x75,0xDF,0x6E, - 0x47,0xF1,0x1A,0x71,0x1D,0x29,0xC5,0x89,0x6F,0xB7,0x62,0x0E,0xAA,0x18,0xBE,0x1B, - 0xFC,0x56,0x3E,0x4B,0xC6,0xD2,0x79,0x20,0x9A,0xDB,0xC0,0xFE,0x78,0xCD,0x5A,0xF4, - 0x1F,0xDD,0xA8,0x33,0x88,0x07,0xC7,0x31,0xB1,0x12,0x10,0x59,0x27,0x80,0xEC,0x5F, - 0x60,0x51,0x7F,0xA9,0x19,0xB5,0x4A,0x0D,0x2D,0xE5,0x7A,0x9F,0x93,0xC9,0x9C,0xEF, - 0xA0,0xE0,0x3B,0x4D,0xAE,0x2A,0xF5,0xB0,0xC8,0xEB,0xBB,0x3C,0x83,0x53,0x99,0x61, - 0x17,0x2B,0x04,0x7E,0xBA,0x77,0xD6,0x26,0xE1,0x69,0x14,0x63,0x55,0x21,0x0C,0x7D -} - -local rcon = { - 0x8d,0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36,0x6c,0xd8,0xab,0x4d,0x9a, - 0x2f,0x5e,0xbc,0x63,0xc6,0x97,0x35,0x6a,0xd4,0xb3,0x7d,0xfa,0xef,0xc5,0x91,0x39, - 0x72,0xe4,0xd3,0xbd,0x61,0xc2,0x9f,0x25,0x4a,0x94,0x33,0x66,0xcc,0x83,0x1d,0x3a, - 0x74,0xe8,0xcb,0x8d,0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36,0x6c,0xd8, - 0xab,0x4d,0x9a,0x2f,0x5e,0xbc,0x63,0xc6,0x97,0x35,0x6a,0xd4,0xb3,0x7d,0xfa,0xef, - 0xc5,0x91,0x39,0x72,0xe4,0xd3,0xbd,0x61,0xc2,0x9f,0x25,0x4a,0x94,0x33,0x66,0xcc, - 0x83,0x1d,0x3a,0x74,0xe8,0xcb,0x8d,0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b, - 0x36,0x6c,0xd8,0xab,0x4d,0x9a,0x2f,0x5e,0xbc,0x63,0xc6,0x97,0x35,0x6a,0xd4,0xb3, - 0x7d,0xfa,0xef,0xc5,0x91,0x39,0x72,0xe4,0xd3,0xbd,0x61,0xc2,0x9f,0x25,0x4a,0x94, - 0x33,0x66,0xcc,0x83,0x1d,0x3a,0x74,0xe8,0xcb,0x8d,0x01,0x02,0x04,0x08,0x10,0x20, - 0x40,0x80,0x1b,0x36,0x6c,0xd8,0xab,0x4d,0x9a,0x2f,0x5e,0xbc,0x63,0xc6,0x97,0x35, - 0x6a,0xd4,0xb3,0x7d,0xfa,0xef,0xc5,0x91,0x39,0x72,0xe4,0xd3,0xbd,0x61,0xc2,0x9f, - 0x25,0x4a,0x94,0x33,0x66,0xcc,0x83,0x1d,0x3a,0x74,0xe8,0xcb,0x8d,0x01,0x02,0x04, - 0x08,0x10,0x20,0x40,0x80,0x1b,0x36,0x6c,0xd8,0xab,0x4d,0x9a,0x2f,0x5e,0xbc,0x63, - 0xc6,0x97,0x35,0x6a,0xd4,0xb3,0x7d,0xfa,0xef,0xc5,0x91,0x39,0x72,0xe4,0xd3,0xbd, - 0x61,0xc2,0x9f,0x25,0x4a,0x94,0x33,0x66,0xcc,0x83,0x1d,0x3a,0x74,0xe8,0xcb,0x8d -} - -local xor4 = { - [0]=0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15, - 1,0,3,2,5,4,7,6,9,8,11,10,13,12,15,14, - 2,3,0,1,6,7,4,5,10,11,8,9,14,15,12,13, - 3,2,1,0,7,6,5,4,11,10,9,8,15,14,13,12, - 4,5,6,7,0,1,2,3,12,13,14,15,8,9,10,11, - 5,4,7,6,1,0,3,2,13,12,15,14,9,8,11,10, - 6,7,4,5,2,3,0,1,14,15,12,13,10,11,8,9, - 7,6,5,4,3,2,1,0,15,14,13,12,11,10,9,8, - 8,9,10,11,12,13,14,15,0,1,2,3,4,5,6,7, - 9,8,11,10,13,12,15,14,1,0,3,2,5,4,7,6, - 10,11,8,9,14,15,12,13,2,3,0,1,6,7,4,5, - 11,10,9,8,15,14,13,12,3,2,1,0,7,6,5,4, - 12,13,14,15,8,9,10,11,4,5,6,7,0,1,2,3, - 13,12,15,14,9,8,11,10,5,4,7,6,1,0,3,2, - 14,15,12,13,10,11,8,9,6,7,4,5,2,3,0,1, - 15,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0, -} - -local function xor8(a, b) - local al = a % 16 - local bl = b % 16 - return 16 * xor4[a - al + (b - bl) / 16] + xor4[16 * al + bl] -end - -local function xor_blocks_8(a, b) - local res = {} - for i = 1, 16 do - res[i] = xor8(a[i], b[i]) - end - return res -end - -local function addRoundKey(state, key) - for i, byte in next, state do - state[i] = xor8(byte, key[i]) - end -end - -local function subBytes(state, s_box) - for i, byte in next, state do - state[i] = s_box[byte] - end -end - -local function shiftRows(state) - state[2], state[6], state[10], state[14] = - state[6], state[10], state[14], state[2] - - state[3], state[7], state[11], state[15] = - state[11], state[15], state[3], state[7] - - state[4], state[8], state[12], state[16] = - state[16], state[4], state[8], state[12] -end - -local function inv_shiftRows(state) - state[2], state[6], state[10], state[14] = - state[14], state[2], state[6], state[10] - - state[3], state[7], state[11], state[15] = - state[11], state[15], state[3], state[7] - - state[4], state[8], state[12], state[16] = - state[8], state[12], state[16], state[4] -end - -local function mixColumns(state) - for i = 0, 3 do - local cur = i*4+1 - local a, b, c, d = state[cur], state[cur + 1], state[cur + 2], state[cur + 3] - state[cur + 0] = xor8(xor8(xor8(GF8x2[a], GF8x3[b]), c), d) - state[cur + 1] = xor8(xor8(xor8(a, GF8x2[b]), GF8x3[c]), d) - state[cur + 2] = xor8(xor8(xor8(a, b), GF8x2[c]), GF8x3[d]) - state[cur + 3] = xor8(xor8(xor8(GF8x3[a], b), c), GF8x2[d]) - end -end - -local function inv_mixColumns(state) -- TODO: fix - for i = 0, 3 do - local cur = i*4+1 - local a, b, c, d = state[cur], state[cur + 1], state[cur + 2], state[cur + 3] - state[cur + 0] = xor8(xor8(xor8(GF8x14[a], GF8x11[b]), GF8x13[c]), GF8x9[d]) - state[cur + 1] = xor8(xor8(xor8(GF8x9[a], GF8x14[b]), GF8x11[c]), GF8x13[d]) - state[cur + 2] = xor8(xor8(xor8(GF8x13[a], GF8x9[b]), GF8x14[c]), GF8x11[d]) - state[cur + 3] = xor8(xor8(xor8(GF8x11[a], GF8x13[b]), GF8x9[c]), GF8x14[d]) - end -end - --- 256-bit key constants -local n = 32 -- number of bytes in the 256-bit encryption key -local b = 240 -- number of bytes in 15 128-bit round keys -local function schedule256(key) - local expanded = {} - for c = 0, n-1 do - expanded[c] = key[c] - end - - local i = 1 - local c = n - local t1, t2, t3, t4 --t - while c < b do - t1 = expanded[c-4] - t2 = expanded[c-3] - t3 = expanded[c-2] - t4 = expanded[c-1] - - if (c % n == 0) then - t1, t2, t3, t4 = xor8(rcon[i+1], s[t2]), s[t3], s[t4], s[t1] - i = i + 1 - end - - if (c % n == 16) then - t1 = s[t1] - t2 = s[t2] - t3 = s[t3] - t4 = s[t4] - end - - t1 = xor8(t1, expanded[c - n]) - expanded[c] = t1 - c = c + 1 - - t2 = xor8(t2, expanded[c - n]) - expanded[c] = t2 - c = c + 1 - - t3 = xor8(t3, expanded[c - n]) - expanded[c] = t3 - c = c + 1 - - t4 = xor8(t4, expanded[c - n]) - expanded[c] = t4 - c = c + 1 - end - - local roundKeys = {} - for round = 0, 14 do - local roundKey = {} - for byte = 0, 15 do - roundKey[byte+1] = expanded[round * 16 + byte] - end - roundKeys[round] = roundKey - end - return roundKeys -end - -local function chunks(text, i) - local first = i * 16 + 1 - if first > #text then - return - end - i = i + 1 - - local chunk = {text:byte(first, first + 15)} - for j = #chunk + 1, 16 do - chunk[j] = 0 - end - - return i, chunk -end - -local function pkcs7_unpad(str) - local len = #str - if len == 0 then return str end - - local pad_len = string.byte(str, len) - if pad_len < 1 or pad_len > 16 then - return nil - end - - for i = len - pad_len + 1, len do - if string.byte(str, i) ~= pad_len then - return nil - end - end - - return string.sub(str, 1, len - pad_len) -end - -local function zero_unpad(str) - local len = #str - while len > 0 and string.byte(str, len) == 0 do - len = len - 1 - end - return string.sub(str, 1, len) -end - -local function unpad(str) - local unpadded = pkcs7_unpad(str) - if unpadded then - return unpadded - else - return zero_unpad(str) - end -end - -local function encrypt(state, roundKeys) - addRoundKey(state, roundKeys[0]) - for round = 1, 13 do - subBytes(state, s) - shiftRows(state) - mixColumns(state) - addRoundKey(state, roundKeys[round]) - end - subBytes(state, s) - shiftRows(state) - addRoundKey(state, roundKeys[14]) -end - -local function decrypt(state, roundKeys) - addRoundKey(state, roundKeys[14]) - inv_shiftRows(state) - subBytes(state, si) - for round = 13, 1, -1 do - addRoundKey(state, roundKeys[round]) - inv_mixColumns(state) - inv_shiftRows(state) - subBytes(state, si) - end - addRoundKey(state, roundKeys[0]) -end - -local function ECB_encrypt(key, originaltext) - local text = {} - local roundKeys = schedule256(key) - local i = 0 - while true do - i, state = chunks(originaltext, i) - if not state then break end - encrypt(state, roundKeys) - text[i] = string.char(unpack(state)) - end - return table.concat(text) -end - -local function ECB_decrypt(key, ciphertext) - local text = {} - local roundKeys = schedule256(key) - local i = 0 - while true do - i, state = chunks(ciphertext, i) - if not state then break end - decrypt(state, roundKeys) - text[i] = string.char(unpack(state)) - end - return unpad(table.concat(text)) -end - -local function CBC_encrypt(key, iv, originaltext) - local roundKeys = schedule256(key) - local text = {} - local prev_block = {unpack(iv)} - local i = 0 - - while true do - i, block = chunks(originaltext, i) - if not block then break end - - local xored = xor_blocks_8(block, prev_block) - encrypt(xored, roundKeys) - text[i] = string.char(unpack(xored)) - prev_block = xored - end - - return table.concat(text) -end - -local function CBC_decrypt(key, iv, ciphertext) - local roundKeys = schedule256(key) - local text = {} - local prev_block = {unpack(iv)} - local i = 0 - - while true do - i, block = chunks(ciphertext, i) - if not block then break end - - local decrypted = {unpack(block)} - - decrypt(decrypted, roundKeys) - local plain_block = xor_blocks_8(decrypted, prev_block) - text[i] = string.char(unpack(plain_block)) - prev_block = block - end - - local result = table.concat(text) - return unpad(result) -end - -return { - ECB = { - encrypt = ECB_encrypt; - decrypt = ECB_decrypt; - }; - CBC = { - encrypt = CBC_encrypt; - decrypt = CBC_decrypt; - }; +-- modified from https://github.com/idiomic/Lua_AES +--[[ + Copyright 2019 Tyler Richard Hoyer + Copyright 2025 dyphire + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +]] + +local unpack = unpack or table.unpack + +local GF8x2 = { + [0]=0x00,0x02,0x04,0x06,0x08,0x0a,0x0c,0x0e,0x10,0x12,0x14,0x16,0x18,0x1a,0x1c,0x1e, + 0x20,0x22,0x24,0x26,0x28,0x2a,0x2c,0x2e,0x30,0x32,0x34,0x36,0x38,0x3a,0x3c,0x3e, + 0x40,0x42,0x44,0x46,0x48,0x4a,0x4c,0x4e,0x50,0x52,0x54,0x56,0x58,0x5a,0x5c,0x5e, + 0x60,0x62,0x64,0x66,0x68,0x6a,0x6c,0x6e,0x70,0x72,0x74,0x76,0x78,0x7a,0x7c,0x7e, + 0x80,0x82,0x84,0x86,0x88,0x8a,0x8c,0x8e,0x90,0x92,0x94,0x96,0x98,0x9a,0x9c,0x9e, + 0xa0,0xa2,0xa4,0xa6,0xa8,0xaa,0xac,0xae,0xb0,0xb2,0xb4,0xb6,0xb8,0xba,0xbc,0xbe, + 0xc0,0xc2,0xc4,0xc6,0xc8,0xca,0xcc,0xce,0xd0,0xd2,0xd4,0xd6,0xd8,0xda,0xdc,0xde, + 0xe0,0xe2,0xe4,0xe6,0xe8,0xea,0xec,0xee,0xf0,0xf2,0xf4,0xf6,0xf8,0xfa,0xfc,0xfe, + 0x1b,0x19,0x1f,0x1d,0x13,0x11,0x17,0x15,0x0b,0x09,0x0f,0x0d,0x03,0x01,0x07,0x05, + 0x3b,0x39,0x3f,0x3d,0x33,0x31,0x37,0x35,0x2b,0x29,0x2f,0x2d,0x23,0x21,0x27,0x25, + 0x5b,0x59,0x5f,0x5d,0x53,0x51,0x57,0x55,0x4b,0x49,0x4f,0x4d,0x43,0x41,0x47,0x45, + 0x7b,0x79,0x7f,0x7d,0x73,0x71,0x77,0x75,0x6b,0x69,0x6f,0x6d,0x63,0x61,0x67,0x65, + 0x9b,0x99,0x9f,0x9d,0x93,0x91,0x97,0x95,0x8b,0x89,0x8f,0x8d,0x83,0x81,0x87,0x85, + 0xbb,0xb9,0xbf,0xbd,0xb3,0xb1,0xb7,0xb5,0xab,0xa9,0xaf,0xad,0xa3,0xa1,0xa7,0xa5, + 0xdb,0xd9,0xdf,0xdd,0xd3,0xd1,0xd7,0xd5,0xcb,0xc9,0xcf,0xcd,0xc3,0xc1,0xc7,0xc5, + 0xfb,0xf9,0xff,0xfd,0xf3,0xf1,0xf7,0xf5,0xeb,0xe9,0xef,0xed,0xe3,0xe1,0xe7,0xe5 + } + +local GF8x3 = { + [0]=0x00,0x03,0x06,0x05,0x0c,0x0f,0x0a,0x09,0x18,0x1b,0x1e,0x1d,0x14,0x17,0x12,0x11, + 0x30,0x33,0x36,0x35,0x3c,0x3f,0x3a,0x39,0x28,0x2b,0x2e,0x2d,0x24,0x27,0x22,0x21, + 0x60,0x63,0x66,0x65,0x6c,0x6f,0x6a,0x69,0x78,0x7b,0x7e,0x7d,0x74,0x77,0x72,0x71, + 0x50,0x53,0x56,0x55,0x5c,0x5f,0x5a,0x59,0x48,0x4b,0x4e,0x4d,0x44,0x47,0x42,0x41, + 0xc0,0xc3,0xc6,0xc5,0xcc,0xcf,0xca,0xc9,0xd8,0xdb,0xde,0xdd,0xd4,0xd7,0xd2,0xd1, + 0xf0,0xf3,0xf6,0xf5,0xfc,0xff,0xfa,0xf9,0xe8,0xeb,0xee,0xed,0xe4,0xe7,0xe2,0xe1, + 0xa0,0xa3,0xa6,0xa5,0xac,0xaf,0xaa,0xa9,0xb8,0xbb,0xbe,0xbd,0xb4,0xb7,0xb2,0xb1, + 0x90,0x93,0x96,0x95,0x9c,0x9f,0x9a,0x99,0x88,0x8b,0x8e,0x8d,0x84,0x87,0x82,0x81, + 0x9b,0x98,0x9d,0x9e,0x97,0x94,0x91,0x92,0x83,0x80,0x85,0x86,0x8f,0x8c,0x89,0x8a, + 0xab,0xa8,0xad,0xae,0xa7,0xa4,0xa1,0xa2,0xb3,0xb0,0xb5,0xb6,0xbf,0xbc,0xb9,0xba, + 0xfb,0xf8,0xfd,0xfe,0xf7,0xf4,0xf1,0xf2,0xe3,0xe0,0xe5,0xe6,0xef,0xec,0xe9,0xea, + 0xcb,0xc8,0xcd,0xce,0xc7,0xc4,0xc1,0xc2,0xd3,0xd0,0xd5,0xd6,0xdf,0xdc,0xd9,0xda, + 0x5b,0x58,0x5d,0x5e,0x57,0x54,0x51,0x52,0x43,0x40,0x45,0x46,0x4f,0x4c,0x49,0x4a, + 0x6b,0x68,0x6d,0x6e,0x67,0x64,0x61,0x62,0x73,0x70,0x75,0x76,0x7f,0x7c,0x79,0x7a, + 0x3b,0x38,0x3d,0x3e,0x37,0x34,0x31,0x32,0x23,0x20,0x25,0x26,0x2f,0x2c,0x29,0x2a, + 0x0b,0x08,0x0d,0x0e,0x07,0x04,0x01,0x02,0x13,0x10,0x15,0x16,0x1f,0x1c,0x19,0x1a +} + +local GF8x9 = { + [0]=0x00,0x09,0x12,0x1b,0x24,0x2d,0x36,0x3f,0x48,0x41,0x5a,0x53,0x6c,0x65,0x7e,0x77, + 0x90,0x99,0x82,0x8b,0xb4,0xbd,0xa6,0xaf,0xd8,0xd1,0xca,0xc3,0xfc,0xf5,0xee,0xe7, + 0x3b,0x32,0x29,0x20,0x1f,0x16,0x0d,0x04,0x73,0x7a,0x61,0x68,0x57,0x5e,0x45,0x4c, + 0xab,0xa2,0xb9,0xb0,0x8f,0x86,0x9d,0x94,0xe3,0xea,0xf1,0xf8,0xc7,0xce,0xd5,0xdc, + 0x76,0x7f,0x64,0x6d,0x52,0x5b,0x40,0x49,0x3e,0x37,0x2c,0x25,0x1a,0x13,0x08,0x01, + 0xe6,0xef,0xf4,0xfd,0xc2,0xcb,0xd0,0xd9,0xae,0xa7,0xbc,0xb5,0x8a,0x83,0x98,0x91, + 0x4d,0x44,0x5f,0x56,0x69,0x60,0x7b,0x72,0x05,0x0c,0x17,0x1e,0x21,0x28,0x33,0x3a, + 0xdd,0xd4,0xcf,0xc6,0xf9,0xf0,0xeb,0xe2,0x95,0x9c,0x87,0x8e,0xb1,0xb8,0xa3,0xaa, + 0xec,0xe5,0xfe,0xf7,0xc8,0xc1,0xda,0xd3,0xa4,0xad,0xb6,0xbf,0x80,0x89,0x92,0x9b, + 0x7c,0x75,0x6e,0x67,0x58,0x51,0x4a,0x43,0x34,0x3d,0x26,0x2f,0x10,0x19,0x02,0x0b, + 0xd7,0xde,0xc5,0xcc,0xf3,0xfa,0xe1,0xe8,0x9f,0x96,0x8d,0x84,0xbb,0xb2,0xa9,0xa0, + 0x47,0x4e,0x55,0x5c,0x63,0x6a,0x71,0x78,0x0f,0x06,0x1d,0x14,0x2b,0x22,0x39,0x30, + 0x9a,0x93,0x88,0x81,0xbe,0xb7,0xac,0xa5,0xd2,0xdb,0xc0,0xc9,0xf6,0xff,0xe4,0xed, + 0x0a,0x03,0x18,0x11,0x2e,0x27,0x3c,0x35,0x42,0x4b,0x50,0x59,0x66,0x6f,0x74,0x7d, + 0xa1,0xa8,0xb3,0xba,0x85,0x8c,0x97,0x9e,0xe9,0xe0,0xfb,0xf2,0xcd,0xc4,0xdf,0xd6, + 0x31,0x38,0x23,0x2a,0x15,0x1c,0x07,0x0e,0x79,0x70,0x6b,0x62,0x5d,0x54,0x4f,0x46 +} + +local GF8x11 = { + [0]=0x00,0x0b,0x16,0x1d,0x2c,0x27,0x3a,0x31,0x58,0x53,0x4e,0x45,0x74,0x7f,0x62,0x69, + 0xb0,0xbb,0xa6,0xad,0x9c,0x97,0x8a,0x81,0xe8,0xe3,0xfe,0xf5,0xc4,0xcf,0xd2,0xd9, + 0x7b,0x70,0x6d,0x66,0x57,0x5c,0x41,0x4a,0x23,0x28,0x35,0x3e,0x0f,0x04,0x19,0x12, + 0xcb,0xc0,0xdd,0xd6,0xe7,0xec,0xf1,0xfa,0x93,0x98,0x85,0x8e,0xbf,0xb4,0xa9,0xa2, + 0xf6,0xfd,0xe0,0xeb,0xda,0xd1,0xcc,0xc7,0xae,0xa5,0xb8,0xb3,0x82,0x89,0x94,0x9f, + 0x46,0x4d,0x50,0x5b,0x6a,0x61,0x7c,0x77,0x1e,0x15,0x08,0x03,0x32,0x39,0x24,0x2f, + 0x8d,0x86,0x9b,0x90,0xa1,0xaa,0xb7,0xbc,0xd5,0xde,0xc3,0xc8,0xf9,0xf2,0xef,0xe4, + 0x3d,0x36,0x2b,0x20,0x11,0x1a,0x07,0x0c,0x65,0x6e,0x73,0x78,0x49,0x42,0x5f,0x54, + 0xf7,0xfc,0xe1,0xea,0xdb,0xd0,0xcd,0xc6,0xaf,0xa4,0xb9,0xb2,0x83,0x88,0x95,0x9e, + 0x47,0x4c,0x51,0x5a,0x6b,0x60,0x7d,0x76,0x1f,0x14,0x09,0x02,0x33,0x38,0x25,0x2e, + 0x8c,0x87,0x9a,0x91,0xa0,0xab,0xb6,0xbd,0xd4,0xdf,0xc2,0xc9,0xf8,0xf3,0xee,0xe5, + 0x3c,0x37,0x2a,0x21,0x10,0x1b,0x06,0x0d,0x64,0x6f,0x72,0x79,0x48,0x43,0x5e,0x55, + 0x01,0x0a,0x17,0x1c,0x2d,0x26,0x3b,0x30,0x59,0x52,0x4f,0x44,0x75,0x7e,0x63,0x68, + 0xb1,0xba,0xa7,0xac,0x9d,0x96,0x8b,0x80,0xe9,0xe2,0xff,0xf4,0xc5,0xce,0xd3,0xd8, + 0x7a,0x71,0x6c,0x67,0x56,0x5d,0x40,0x4b,0x22,0x29,0x34,0x3f,0x0e,0x05,0x18,0x13, + 0xca,0xc1,0xdc,0xd7,0xe6,0xed,0xf0,0xfb,0x92,0x99,0x84,0x8f,0xbe,0xb5,0xa8,0xa3 +} + +local GF8x13 = { + [0]=0x00,0x0d,0x1a,0x17,0x34,0x39,0x2e,0x23,0x68,0x65,0x72,0x7f,0x5c,0x51,0x46,0x4b, + 0xd0,0xdd,0xca,0xc7,0xe4,0xe9,0xfe,0xf3,0xb8,0xb5,0xa2,0xaf,0x8c,0x81,0x96,0x9b, + 0xbb,0xb6,0xa1,0xac,0x8f,0x82,0x95,0x98,0xd3,0xde,0xc9,0xc4,0xe7,0xea,0xfd,0xf0, + 0x6b,0x66,0x71,0x7c,0x5f,0x52,0x45,0x48,0x03,0x0e,0x19,0x14,0x37,0x3a,0x2d,0x20, + 0x6d,0x60,0x77,0x7a,0x59,0x54,0x43,0x4e,0x05,0x08,0x1f,0x12,0x31,0x3c,0x2b,0x26, + 0xbd,0xb0,0xa7,0xaa,0x89,0x84,0x93,0x9e,0xd5,0xd8,0xcf,0xc2,0xe1,0xec,0xfb,0xf6, + 0xd6,0xdb,0xcc,0xc1,0xe2,0xef,0xf8,0xf5,0xbe,0xb3,0xa4,0xa9,0x8a,0x87,0x90,0x9d, + 0x06,0x0b,0x1c,0x11,0x32,0x3f,0x28,0x25,0x6e,0x63,0x74,0x79,0x5a,0x57,0x40,0x4d, + 0xda,0xd7,0xc0,0xcd,0xee,0xe3,0xf4,0xf9,0xb2,0xbf,0xa8,0xa5,0x86,0x8b,0x9c,0x91, + 0x0a,0x07,0x10,0x1d,0x3e,0x33,0x24,0x29,0x62,0x6f,0x78,0x75,0x56,0x5b,0x4c,0x41, + 0x61,0x6c,0x7b,0x76,0x55,0x58,0x4f,0x42,0x09,0x04,0x13,0x1e,0x3d,0x30,0x27,0x2a, + 0xb1,0xbc,0xab,0xa6,0x85,0x88,0x9f,0x92,0xd9,0xd4,0xc3,0xce,0xed,0xe0,0xf7,0xfa, + 0xb7,0xba,0xad,0xa0,0x83,0x8e,0x99,0x94,0xdf,0xd2,0xc5,0xc8,0xeb,0xe6,0xf1,0xfc, + 0x67,0x6a,0x7d,0x70,0x53,0x5e,0x49,0x44,0x0f,0x02,0x15,0x18,0x3b,0x36,0x21,0x2c, + 0x0c,0x01,0x16,0x1b,0x38,0x35,0x22,0x2f,0x64,0x69,0x7e,0x73,0x50,0x5d,0x4a,0x47, + 0xdc,0xd1,0xc6,0xcb,0xe8,0xe5,0xf2,0xff,0xb4,0xb9,0xae,0xa3,0x80,0x8d,0x9a,0x97 +} + +local GF8x14 = { + [0]=0x00,0x0e,0x1c,0x12,0x38,0x36,0x24,0x2a,0x70,0x7e,0x6c,0x62,0x48,0x46,0x54,0x5a, + 0xe0,0xee,0xfc,0xf2,0xd8,0xd6,0xc4,0xca,0x90,0x9e,0x8c,0x82,0xa8,0xa6,0xb4,0xba, + 0xdb,0xd5,0xc7,0xc9,0xe3,0xed,0xff,0xf1,0xab,0xa5,0xb7,0xb9,0x93,0x9d,0x8f,0x81, + 0x3b,0x35,0x27,0x29,0x03,0x0d,0x1f,0x11,0x4b,0x45,0x57,0x59,0x73,0x7d,0x6f,0x61, + 0xad,0xa3,0xb1,0xbf,0x95,0x9b,0x89,0x87,0xdd,0xd3,0xc1,0xcf,0xe5,0xeb,0xf9,0xf7, + 0x4d,0x43,0x51,0x5f,0x75,0x7b,0x69,0x67,0x3d,0x33,0x21,0x2f,0x05,0x0b,0x19,0x17, + 0x76,0x78,0x6a,0x64,0x4e,0x40,0x52,0x5c,0x06,0x08,0x1a,0x14,0x3e,0x30,0x22,0x2c, + 0x96,0x98,0x8a,0x84,0xae,0xa0,0xb2,0xbc,0xe6,0xe8,0xfa,0xf4,0xde,0xd0,0xc2,0xcc, + 0x41,0x4f,0x5d,0x53,0x79,0x77,0x65,0x6b,0x31,0x3f,0x2d,0x23,0x09,0x07,0x15,0x1b, + 0xa1,0xaf,0xbd,0xb3,0x99,0x97,0x85,0x8b,0xd1,0xdf,0xcd,0xc3,0xe9,0xe7,0xf5,0xfb, + 0x9a,0x94,0x86,0x88,0xa2,0xac,0xbe,0xb0,0xea,0xe4,0xf6,0xf8,0xd2,0xdc,0xce,0xc0, + 0x7a,0x74,0x66,0x68,0x42,0x4c,0x5e,0x50,0x0a,0x04,0x16,0x18,0x32,0x3c,0x2e,0x20, + 0xec,0xe2,0xf0,0xfe,0xd4,0xda,0xc8,0xc6,0x9c,0x92,0x80,0x8e,0xa4,0xaa,0xb8,0xb6, + 0x0c,0x02,0x10,0x1e,0x34,0x3a,0x28,0x26,0x7c,0x72,0x60,0x6e,0x44,0x4a,0x58,0x56, + 0x37,0x39,0x2b,0x25,0x0f,0x01,0x13,0x1d,0x47,0x49,0x5b,0x55,0x7f,0x71,0x63,0x6d, + 0xd7,0xd9,0xcb,0xc5,0xef,0xe1,0xf3,0xfd,0xa7,0xa9,0xbb,0xb5,0x9f,0x91,0x83,0x8d +} + +local s = { + [0]=0x63,0x7C,0x77,0x7B,0xF2,0x6B,0x6F,0xC5,0x30,0x01,0x67,0x2B,0xFE,0xD7,0xAB,0x76, + 0xCA,0x82,0xC9,0x7D,0xFA,0x59,0x47,0xF0,0xAD,0xD4,0xA2,0xAF,0x9C,0xA4,0x72,0xC0, + 0xB7,0xFD,0x93,0x26,0x36,0x3F,0xF7,0xCC,0x34,0xA5,0xE5,0xF1,0x71,0xD8,0x31,0x15, + 0x04,0xC7,0x23,0xC3,0x18,0x96,0x05,0x9A,0x07,0x12,0x80,0xE2,0xEB,0x27,0xB2,0x75, + 0x09,0x83,0x2C,0x1A,0x1B,0x6E,0x5A,0xA0,0x52,0x3B,0xD6,0xB3,0x29,0xE3,0x2F,0x84, + 0x53,0xD1,0x00,0xED,0x20,0xFC,0xB1,0x5B,0x6A,0xCB,0xBE,0x39,0x4A,0x4C,0x58,0xCF, + 0xD0,0xEF,0xAA,0xFB,0x43,0x4D,0x33,0x85,0x45,0xF9,0x02,0x7F,0x50,0x3C,0x9F,0xA8, + 0x51,0xA3,0x40,0x8F,0x92,0x9D,0x38,0xF5,0xBC,0xB6,0xDA,0x21,0x10,0xFF,0xF3,0xD2, + 0xCD,0x0C,0x13,0xEC,0x5F,0x97,0x44,0x17,0xC4,0xA7,0x7E,0x3D,0x64,0x5D,0x19,0x73, + 0x60,0x81,0x4F,0xDC,0x22,0x2A,0x90,0x88,0x46,0xEE,0xB8,0x14,0xDE,0x5E,0x0B,0xDB, + 0xE0,0x32,0x3A,0x0A,0x49,0x06,0x24,0x5C,0xC2,0xD3,0xAC,0x62,0x91,0x95,0xE4,0x79, + 0xE7,0xC8,0x37,0x6D,0x8D,0xD5,0x4E,0xA9,0x6C,0x56,0xF4,0xEA,0x65,0x7A,0xAE,0x08, + 0xBA,0x78,0x25,0x2E,0x1C,0xA6,0xB4,0xC6,0xE8,0xDD,0x74,0x1F,0x4B,0xBD,0x8B,0x8A, + 0x70,0x3E,0xB5,0x66,0x48,0x03,0xF6,0x0E,0x61,0x35,0x57,0xB9,0x86,0xC1,0x1D,0x9E, + 0xE1,0xF8,0x98,0x11,0x69,0xD9,0x8E,0x94,0x9B,0x1E,0x87,0xE9,0xCE,0x55,0x28,0xDF, + 0x8C,0xA1,0x89,0x0D,0xBF,0xE6,0x42,0x68,0x41,0x99,0x2D,0x0F,0xB0,0x54,0xBB,0x16 +} + +local si = { + [0]=0x52,0x09,0x6A,0xD5,0x30,0x36,0xA5,0x38,0xBF,0x40,0xA3,0x9E,0x81,0xF3,0xD7,0xFB, + 0x7C,0xE3,0x39,0x82,0x9B,0x2F,0xFF,0x87,0x34,0x8E,0x43,0x44,0xC4,0xDE,0xE9,0xCB, + 0x54,0x7B,0x94,0x32,0xA6,0xC2,0x23,0x3D,0xEE,0x4C,0x95,0x0B,0x42,0xFA,0xC3,0x4E, + 0x08,0x2E,0xA1,0x66,0x28,0xD9,0x24,0xB2,0x76,0x5B,0xA2,0x49,0x6D,0x8B,0xD1,0x25, + 0x72,0xF8,0xF6,0x64,0x86,0x68,0x98,0x16,0xD4,0xA4,0x5C,0xCC,0x5D,0x65,0xB6,0x92, + 0x6C,0x70,0x48,0x50,0xFD,0xED,0xB9,0xDA,0x5E,0x15,0x46,0x57,0xA7,0x8D,0x9D,0x84, + 0x90,0xD8,0xAB,0x00,0x8C,0xBC,0xD3,0x0A,0xF7,0xE4,0x58,0x05,0xB8,0xB3,0x45,0x06, + 0xD0,0x2C,0x1E,0x8F,0xCA,0x3F,0x0F,0x02,0xC1,0xAF,0xBD,0x03,0x01,0x13,0x8A,0x6B, + 0x3A,0x91,0x11,0x41,0x4F,0x67,0xDC,0xEA,0x97,0xF2,0xCF,0xCE,0xF0,0xB4,0xE6,0x73, + 0x96,0xAC,0x74,0x22,0xE7,0xAD,0x35,0x85,0xE2,0xF9,0x37,0xE8,0x1C,0x75,0xDF,0x6E, + 0x47,0xF1,0x1A,0x71,0x1D,0x29,0xC5,0x89,0x6F,0xB7,0x62,0x0E,0xAA,0x18,0xBE,0x1B, + 0xFC,0x56,0x3E,0x4B,0xC6,0xD2,0x79,0x20,0x9A,0xDB,0xC0,0xFE,0x78,0xCD,0x5A,0xF4, + 0x1F,0xDD,0xA8,0x33,0x88,0x07,0xC7,0x31,0xB1,0x12,0x10,0x59,0x27,0x80,0xEC,0x5F, + 0x60,0x51,0x7F,0xA9,0x19,0xB5,0x4A,0x0D,0x2D,0xE5,0x7A,0x9F,0x93,0xC9,0x9C,0xEF, + 0xA0,0xE0,0x3B,0x4D,0xAE,0x2A,0xF5,0xB0,0xC8,0xEB,0xBB,0x3C,0x83,0x53,0x99,0x61, + 0x17,0x2B,0x04,0x7E,0xBA,0x77,0xD6,0x26,0xE1,0x69,0x14,0x63,0x55,0x21,0x0C,0x7D +} + +local rcon = { + 0x8d,0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36,0x6c,0xd8,0xab,0x4d,0x9a, + 0x2f,0x5e,0xbc,0x63,0xc6,0x97,0x35,0x6a,0xd4,0xb3,0x7d,0xfa,0xef,0xc5,0x91,0x39, + 0x72,0xe4,0xd3,0xbd,0x61,0xc2,0x9f,0x25,0x4a,0x94,0x33,0x66,0xcc,0x83,0x1d,0x3a, + 0x74,0xe8,0xcb,0x8d,0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36,0x6c,0xd8, + 0xab,0x4d,0x9a,0x2f,0x5e,0xbc,0x63,0xc6,0x97,0x35,0x6a,0xd4,0xb3,0x7d,0xfa,0xef, + 0xc5,0x91,0x39,0x72,0xe4,0xd3,0xbd,0x61,0xc2,0x9f,0x25,0x4a,0x94,0x33,0x66,0xcc, + 0x83,0x1d,0x3a,0x74,0xe8,0xcb,0x8d,0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b, + 0x36,0x6c,0xd8,0xab,0x4d,0x9a,0x2f,0x5e,0xbc,0x63,0xc6,0x97,0x35,0x6a,0xd4,0xb3, + 0x7d,0xfa,0xef,0xc5,0x91,0x39,0x72,0xe4,0xd3,0xbd,0x61,0xc2,0x9f,0x25,0x4a,0x94, + 0x33,0x66,0xcc,0x83,0x1d,0x3a,0x74,0xe8,0xcb,0x8d,0x01,0x02,0x04,0x08,0x10,0x20, + 0x40,0x80,0x1b,0x36,0x6c,0xd8,0xab,0x4d,0x9a,0x2f,0x5e,0xbc,0x63,0xc6,0x97,0x35, + 0x6a,0xd4,0xb3,0x7d,0xfa,0xef,0xc5,0x91,0x39,0x72,0xe4,0xd3,0xbd,0x61,0xc2,0x9f, + 0x25,0x4a,0x94,0x33,0x66,0xcc,0x83,0x1d,0x3a,0x74,0xe8,0xcb,0x8d,0x01,0x02,0x04, + 0x08,0x10,0x20,0x40,0x80,0x1b,0x36,0x6c,0xd8,0xab,0x4d,0x9a,0x2f,0x5e,0xbc,0x63, + 0xc6,0x97,0x35,0x6a,0xd4,0xb3,0x7d,0xfa,0xef,0xc5,0x91,0x39,0x72,0xe4,0xd3,0xbd, + 0x61,0xc2,0x9f,0x25,0x4a,0x94,0x33,0x66,0xcc,0x83,0x1d,0x3a,0x74,0xe8,0xcb,0x8d +} + +local xor4 = { + [0]=0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15, + 1,0,3,2,5,4,7,6,9,8,11,10,13,12,15,14, + 2,3,0,1,6,7,4,5,10,11,8,9,14,15,12,13, + 3,2,1,0,7,6,5,4,11,10,9,8,15,14,13,12, + 4,5,6,7,0,1,2,3,12,13,14,15,8,9,10,11, + 5,4,7,6,1,0,3,2,13,12,15,14,9,8,11,10, + 6,7,4,5,2,3,0,1,14,15,12,13,10,11,8,9, + 7,6,5,4,3,2,1,0,15,14,13,12,11,10,9,8, + 8,9,10,11,12,13,14,15,0,1,2,3,4,5,6,7, + 9,8,11,10,13,12,15,14,1,0,3,2,5,4,7,6, + 10,11,8,9,14,15,12,13,2,3,0,1,6,7,4,5, + 11,10,9,8,15,14,13,12,3,2,1,0,7,6,5,4, + 12,13,14,15,8,9,10,11,4,5,6,7,0,1,2,3, + 13,12,15,14,9,8,11,10,5,4,7,6,1,0,3,2, + 14,15,12,13,10,11,8,9,6,7,4,5,2,3,0,1, + 15,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0, +} + +local function xor8(a, b) + local al = a % 16 + local bl = b % 16 + return 16 * xor4[a - al + (b - bl) / 16] + xor4[16 * al + bl] +end + +local function xor_blocks_8(a, b) + local res = {} + for i = 1, 16 do + res[i] = xor8(a[i], b[i]) + end + return res +end + +local function addRoundKey(state, key) + for i, byte in next, state do + state[i] = xor8(byte, key[i]) + end +end + +local function subBytes(state, s_box) + for i, byte in next, state do + state[i] = s_box[byte] + end +end + +local function shiftRows(state) + state[2], state[6], state[10], state[14] = + state[6], state[10], state[14], state[2] + + state[3], state[7], state[11], state[15] = + state[11], state[15], state[3], state[7] + + state[4], state[8], state[12], state[16] = + state[16], state[4], state[8], state[12] +end + +local function inv_shiftRows(state) + state[2], state[6], state[10], state[14] = + state[14], state[2], state[6], state[10] + + state[3], state[7], state[11], state[15] = + state[11], state[15], state[3], state[7] + + state[4], state[8], state[12], state[16] = + state[8], state[12], state[16], state[4] +end + +local function mixColumns(state) + for i = 0, 3 do + local cur = i*4+1 + local a, b, c, d = state[cur], state[cur + 1], state[cur + 2], state[cur + 3] + state[cur + 0] = xor8(xor8(xor8(GF8x2[a], GF8x3[b]), c), d) + state[cur + 1] = xor8(xor8(xor8(a, GF8x2[b]), GF8x3[c]), d) + state[cur + 2] = xor8(xor8(xor8(a, b), GF8x2[c]), GF8x3[d]) + state[cur + 3] = xor8(xor8(xor8(GF8x3[a], b), c), GF8x2[d]) + end +end + +local function inv_mixColumns(state) -- TODO: fix + for i = 0, 3 do + local cur = i*4+1 + local a, b, c, d = state[cur], state[cur + 1], state[cur + 2], state[cur + 3] + state[cur + 0] = xor8(xor8(xor8(GF8x14[a], GF8x11[b]), GF8x13[c]), GF8x9[d]) + state[cur + 1] = xor8(xor8(xor8(GF8x9[a], GF8x14[b]), GF8x11[c]), GF8x13[d]) + state[cur + 2] = xor8(xor8(xor8(GF8x13[a], GF8x9[b]), GF8x14[c]), GF8x11[d]) + state[cur + 3] = xor8(xor8(xor8(GF8x11[a], GF8x13[b]), GF8x9[c]), GF8x14[d]) + end +end + +-- 256-bit key constants +local n = 32 -- number of bytes in the 256-bit encryption key +local b = 240 -- number of bytes in 15 128-bit round keys +local function schedule256(key) + local expanded = {} + for c = 0, n-1 do + expanded[c] = key[c] + end + + local i = 1 + local c = n + local t1, t2, t3, t4 --t + while c < b do + t1 = expanded[c-4] + t2 = expanded[c-3] + t3 = expanded[c-2] + t4 = expanded[c-1] + + if (c % n == 0) then + t1, t2, t3, t4 = xor8(rcon[i+1], s[t2]), s[t3], s[t4], s[t1] + i = i + 1 + end + + if (c % n == 16) then + t1 = s[t1] + t2 = s[t2] + t3 = s[t3] + t4 = s[t4] + end + + t1 = xor8(t1, expanded[c - n]) + expanded[c] = t1 + c = c + 1 + + t2 = xor8(t2, expanded[c - n]) + expanded[c] = t2 + c = c + 1 + + t3 = xor8(t3, expanded[c - n]) + expanded[c] = t3 + c = c + 1 + + t4 = xor8(t4, expanded[c - n]) + expanded[c] = t4 + c = c + 1 + end + + local roundKeys = {} + for round = 0, 14 do + local roundKey = {} + for byte = 0, 15 do + roundKey[byte+1] = expanded[round * 16 + byte] + end + roundKeys[round] = roundKey + end + return roundKeys +end + +local function chunks(text, i) + local first = i * 16 + 1 + if first > #text then + return + end + i = i + 1 + + local chunk = {text:byte(first, first + 15)} + for j = #chunk + 1, 16 do + chunk[j] = 0 + end + + return i, chunk +end + +local function pkcs7_unpad(str) + local len = #str + if len == 0 then return str end + + local pad_len = string.byte(str, len) + if pad_len < 1 or pad_len > 16 then + return nil + end + + for i = len - pad_len + 1, len do + if string.byte(str, i) ~= pad_len then + return nil + end + end + + return string.sub(str, 1, len - pad_len) +end + +local function zero_unpad(str) + local len = #str + while len > 0 and string.byte(str, len) == 0 do + len = len - 1 + end + return string.sub(str, 1, len) +end + +local function unpad(str) + local unpadded = pkcs7_unpad(str) + if unpadded then + return unpadded + else + return zero_unpad(str) + end +end + +local function encrypt(state, roundKeys) + addRoundKey(state, roundKeys[0]) + for round = 1, 13 do + subBytes(state, s) + shiftRows(state) + mixColumns(state) + addRoundKey(state, roundKeys[round]) + end + subBytes(state, s) + shiftRows(state) + addRoundKey(state, roundKeys[14]) +end + +local function decrypt(state, roundKeys) + addRoundKey(state, roundKeys[14]) + inv_shiftRows(state) + subBytes(state, si) + for round = 13, 1, -1 do + addRoundKey(state, roundKeys[round]) + inv_mixColumns(state) + inv_shiftRows(state) + subBytes(state, si) + end + addRoundKey(state, roundKeys[0]) +end + +local function ECB_encrypt(key, originaltext) + local text = {} + local roundKeys = schedule256(key) + local i = 0 + while true do + i, state = chunks(originaltext, i) + if not state then break end + encrypt(state, roundKeys) + text[i] = string.char(unpack(state)) + end + return table.concat(text) +end + +local function ECB_decrypt(key, ciphertext) + local text = {} + local roundKeys = schedule256(key) + local i = 0 + while true do + i, state = chunks(ciphertext, i) + if not state then break end + decrypt(state, roundKeys) + text[i] = string.char(unpack(state)) + end + return unpad(table.concat(text)) +end + +local function CBC_encrypt(key, iv, originaltext) + local roundKeys = schedule256(key) + local text = {} + local prev_block = {unpack(iv)} + local i = 0 + + while true do + i, block = chunks(originaltext, i) + if not block then break end + + local xored = xor_blocks_8(block, prev_block) + encrypt(xored, roundKeys) + text[i] = string.char(unpack(xored)) + prev_block = xored + end + + return table.concat(text) +end + +local function CBC_decrypt(key, iv, ciphertext) + local roundKeys = schedule256(key) + local text = {} + local prev_block = {unpack(iv)} + local i = 0 + + while true do + i, block = chunks(ciphertext, i) + if not block then break end + + local decrypted = {unpack(block)} + + decrypt(decrypted, roundKeys) + local plain_block = xor_blocks_8(decrypted, prev_block) + text[i] = string.char(unpack(plain_block)) + prev_block = block + end + + local result = table.concat(text) + return unpad(result) +end + +return { + ECB = { + encrypt = ECB_encrypt; + decrypt = ECB_decrypt; + }; + CBC = { + encrypt = CBC_encrypt; + decrypt = CBC_decrypt; + }; } \ No newline at end of file diff --git a/scripts/uosc_danmaku/modules/hash.lua b/scripts/uosc_danmaku/modules/hash.lua index 50240a4..22d434e 100644 --- a/scripts/uosc_danmaku/modules/hash.lua +++ b/scripts/uosc_danmaku/modules/hash.lua @@ -1,192 +1,192 @@ ---[[ - - sha256 -- public domain Lua SHA-256 implementation - no warranty implied; use at your own risk - - author: dyphire - - COMPATIBILITY - - Lua 5.1+, LuaJIT - - LICENSE: MIT License - ---]] - -local unpack = unpack or table.unpack - -local function band(a,b) - local res = 0 - local bit = 1 - for i = 0,31 do - local aa = a % 2 - local bb = b % 2 - if aa == 1 and bb == 1 then - res = res + bit - end - a = (a - aa) / 2 - b = (b - bb) / 2 - bit = bit * 2 - end - return res -end - -local function bor(a,b) - local res = 0 - local bit = 1 - for i = 0,31 do - local aa = a % 2 - local bb = b % 2 - if aa == 1 or bb == 1 then - res = res + bit - end - a = (a - aa) / 2 - b = (b - bb) / 2 - bit = bit * 2 - end - return res -end - -local function bxor(a,b) - local res = 0 - local bit = 1 - for i = 0,31 do - local aa = a % 2 - local bb = b % 2 - if (aa + bb) == 1 then - res = res + bit - end - a = (a - aa) / 2 - b = (b - bb) / 2 - bit = bit * 2 - end - return res -end - -local function bnot(a) - return 0xFFFFFFFF - a -end - -local function lshift(a,n) - return (a * 2^n) % 2^32 -end - -local function rshift(a,n) - return math.floor(a / 2^n) % 2^32 -end - -local function bit_ror(x, n) - return bor(rshift(x, n), lshift(x, 32 - n)) -end - -local function sha256(message) - local k = { - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, - 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, - 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, - 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, - 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, - 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, - 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, - 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 - } - - local function preprocess(msg) - local len = #msg - local bitLen = len * 8 - msg = msg .. "\128" - - local zeroPad = 64 - ((len + 9) % 64) - if zeroPad ~= 64 then - msg = msg .. string.rep("\0", zeroPad) - end - - msg = msg .. string.char( - rshift(bitLen, 56) % 256, - rshift(bitLen, 48) % 256, - rshift(bitLen, 40) % 256, - rshift(bitLen, 32) % 256, - rshift(bitLen, 24) % 256, - rshift(bitLen, 16) % 256, - rshift(bitLen, 8) % 256, - bitLen % 256 - ) - return msg - end - - local function chunkify(msg) - local chunks = {} - for i = 1, #msg, 64 do - table.insert(chunks, msg:sub(i, i + 63)) - end - return chunks - end - - local function processChunk(chunk, hash) - local w = {} - - for i = 1, 64 do - if i <= 16 then - w[i] = lshift(string.byte(chunk, (i - 1) * 4 + 1), 24) + - lshift(string.byte(chunk, (i - 1) * 4 + 2), 16) + - lshift(string.byte(chunk, (i - 1) * 4 + 3), 8) + - string.byte(chunk, (i - 1) * 4 + 4) - else - local s0 = bxor(bxor(bit_ror(w[i - 15], 7), bit_ror(w[i - 15], 18)), rshift(w[i - 15], 3)) - local s1 = bxor(bxor(bit_ror(w[i - 2], 17), bit_ror(w[i - 2], 19)), rshift(w[i - 2], 10)) - w[i] = (w[i - 16] + s0 + w[i - 7] + s1) % 2^32 - end - end - - local a, b, c, d, e, f, g, h = unpack(hash) - - for i = 1, 64 do - local s1 = bxor(bxor(bit_ror(e, 6), bit_ror(e, 11)), bit_ror(e, 25)) - local ch = bxor(band(e, f), band(bnot(e), g)) - local temp1 = (h + s1 + ch + k[i] + w[i]) % 2^32 - local s0 = bxor(bxor(bit_ror(a, 2), bit_ror(a, 13)), bit_ror(a, 22)) - local maj = bxor(bxor(band(a, b), band(a, c)), band(b, c)) - local temp2 = (s0 + maj) % 2^32 - - h = g - g = f - f = e - e = (d + temp1) % 2^32 - d = c - c = b - b = a - a = (temp1 + temp2) % 2^32 - end - - return - (hash[1] + a) % 2^32, - (hash[2] + b) % 2^32, - (hash[3] + c) % 2^32, - (hash[4] + d) % 2^32, - (hash[5] + e) % 2^32, - (hash[6] + f) % 2^32, - (hash[7] + g) % 2^32, - (hash[8] + h) % 2^32 - end - - message = preprocess(message) - local chunks = chunkify(message) - - local hash = { - 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, - 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 - } - - for _, chunk in ipairs(chunks) do - hash = {processChunk(chunk, hash)} - end - - local result = "" - for _, h in ipairs(hash) do - result = result .. string.format("%08x", h) - end - - return result -end - -return sha256 \ No newline at end of file +--[[ + + sha256 -- public domain Lua SHA-256 implementation + no warranty implied; use at your own risk + + author: dyphire + + COMPATIBILITY + + Lua 5.1+, LuaJIT + + LICENSE: MIT License + +--]] + +local unpack = unpack or table.unpack + +local function band(a,b) + local res = 0 + local bit = 1 + for i = 0,31 do + local aa = a % 2 + local bb = b % 2 + if aa == 1 and bb == 1 then + res = res + bit + end + a = (a - aa) / 2 + b = (b - bb) / 2 + bit = bit * 2 + end + return res +end + +local function bor(a,b) + local res = 0 + local bit = 1 + for i = 0,31 do + local aa = a % 2 + local bb = b % 2 + if aa == 1 or bb == 1 then + res = res + bit + end + a = (a - aa) / 2 + b = (b - bb) / 2 + bit = bit * 2 + end + return res +end + +local function bxor(a,b) + local res = 0 + local bit = 1 + for i = 0,31 do + local aa = a % 2 + local bb = b % 2 + if (aa + bb) == 1 then + res = res + bit + end + a = (a - aa) / 2 + b = (b - bb) / 2 + bit = bit * 2 + end + return res +end + +local function bnot(a) + return 0xFFFFFFFF - a +end + +local function lshift(a,n) + return (a * 2^n) % 2^32 +end + +local function rshift(a,n) + return math.floor(a / 2^n) % 2^32 +end + +local function bit_ror(x, n) + return bor(rshift(x, n), lshift(x, 32 - n)) +end + +local function sha256(message) + local k = { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 + } + + local function preprocess(msg) + local len = #msg + local bitLen = len * 8 + msg = msg .. "\128" + + local zeroPad = 64 - ((len + 9) % 64) + if zeroPad ~= 64 then + msg = msg .. string.rep("\0", zeroPad) + end + + msg = msg .. string.char( + rshift(bitLen, 56) % 256, + rshift(bitLen, 48) % 256, + rshift(bitLen, 40) % 256, + rshift(bitLen, 32) % 256, + rshift(bitLen, 24) % 256, + rshift(bitLen, 16) % 256, + rshift(bitLen, 8) % 256, + bitLen % 256 + ) + return msg + end + + local function chunkify(msg) + local chunks = {} + for i = 1, #msg, 64 do + table.insert(chunks, msg:sub(i, i + 63)) + end + return chunks + end + + local function processChunk(chunk, hash) + local w = {} + + for i = 1, 64 do + if i <= 16 then + w[i] = lshift(string.byte(chunk, (i - 1) * 4 + 1), 24) + + lshift(string.byte(chunk, (i - 1) * 4 + 2), 16) + + lshift(string.byte(chunk, (i - 1) * 4 + 3), 8) + + string.byte(chunk, (i - 1) * 4 + 4) + else + local s0 = bxor(bxor(bit_ror(w[i - 15], 7), bit_ror(w[i - 15], 18)), rshift(w[i - 15], 3)) + local s1 = bxor(bxor(bit_ror(w[i - 2], 17), bit_ror(w[i - 2], 19)), rshift(w[i - 2], 10)) + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) % 2^32 + end + end + + local a, b, c, d, e, f, g, h = unpack(hash) + + for i = 1, 64 do + local s1 = bxor(bxor(bit_ror(e, 6), bit_ror(e, 11)), bit_ror(e, 25)) + local ch = bxor(band(e, f), band(bnot(e), g)) + local temp1 = (h + s1 + ch + k[i] + w[i]) % 2^32 + local s0 = bxor(bxor(bit_ror(a, 2), bit_ror(a, 13)), bit_ror(a, 22)) + local maj = bxor(bxor(band(a, b), band(a, c)), band(b, c)) + local temp2 = (s0 + maj) % 2^32 + + h = g + g = f + f = e + e = (d + temp1) % 2^32 + d = c + c = b + b = a + a = (temp1 + temp2) % 2^32 + end + + return + (hash[1] + a) % 2^32, + (hash[2] + b) % 2^32, + (hash[3] + c) % 2^32, + (hash[4] + d) % 2^32, + (hash[5] + e) % 2^32, + (hash[6] + f) % 2^32, + (hash[7] + g) % 2^32, + (hash[8] + h) % 2^32 + end + + message = preprocess(message) + local chunks = chunkify(message) + + local hash = { + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 + } + + for _, chunk in ipairs(chunks) do + hash = {processChunk(chunk, hash)} + end + + local result = "" + for _, h in ipairs(hash) do + result = result .. string.format("%08x", h) + end + + return result +end + +return sha256 \ No newline at end of file diff --git a/scripts/uosc_danmaku/modules/inflate.lua b/scripts/uosc_danmaku/modules/inflate.lua new file mode 100644 index 0000000..e468d4e --- /dev/null +++ b/scripts/uosc_danmaku/modules/inflate.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc_danmaku/modules/menu.lua b/scripts/uosc_danmaku/modules/menu.lua index 691f30b..99ea535 100644 --- a/scripts/uosc_danmaku/modules/menu.lua +++ b/scripts/uosc_danmaku/modules/menu.lua @@ -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 = "获取数据失败" - if uosc_available then - update_menu_uosc(menu_type, menu_title, message, footnote, menu_cmd, query) - else - show_message(message, 3) + 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 - 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) + 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) + latest_menu_anime = utils.format_json(final_items) + if not ctx.first_opened.val and input_loaded and #final_items > 0 then + ctx.first_opened.val = true + show_message("", 0) + input.terminate() + mp.add_timeout(0.1, function() + open_menu_select(final_items) + end) + end end - 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 - show_message("", 0) - mp.add_timeout(0.1, function() - latest_menu_anime = utils.format_json(items) - open_menu_select(items) - 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,60 +287,70 @@ function get_episodes(animeTitle, bangumiId) return end - local res = mp.command_native({ name = 'subprocess', capture_stdout = true, capture_stderr = true, args = args }) - - if not res.status or res.status ~= 0 then - local message = "获取数据失败" - if uosc_available then - update_menu_uosc(menu_type, menu_title, message, footnote) - else - show_message(message, 3) + 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 - msg.error("HTTP 请求失败:" .. res.stderr) - end - local response = utils.parse_json(res.stdout) - - if not response or not response.bangumi or not response.bangumi.episodes then - local message = "无结果" - if uosc_available then - update_menu_uosc(menu_type, menu_title, message, footnote) - else - show_message(message, 3) + 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 请求失败:" .. tostring(err)) + return end - msg.info("无结果") - return - end - table.insert(items, { - title = "← 返回搜索结果", - value = { "script-message-to", mp.get_script_name(), "open-latest-menu-anime", latest_menu_anime }, - keep_open = false, - selectable = true, - }) + 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 + update_menu_uosc(menu_type, menu_title, message, footnote) + else + show_message(message, 3) + end + msg.info("无结果") + return + end - for _, episode in ipairs(response.bangumi.episodes) do table.insert(items, { - title = episode.episodeTitle, - hint = episode.episodeNumber, - value = { "script-message-to", mp.get_script_name(), "load-danmaku", - animeTitle, episode.episodeTitle, episode.episodeId }, + title = "← 返回搜索结果", + value = { "script-message-to", mp.get_script_name(), "open-latest-menu-anime", latest_menu_anime }, keep_open = false, selectable = true, }) - end - if uosc_available then - footnote = mp.get_property("filename") - update_menu_uosc(menu_type, menu_title, items, footnote) - elseif input_loaded then - mp.add_timeout(0.1, function() - open_menu_select(items) - end) - end + for _, episode in ipairs(response.bangumi.episodes) do + table.insert(items, { + title = episode.episodeTitle, + hint = episode.episodeNumber, + value = { "script-message-to", mp.get_script_name(), "load-danmaku", + animeTitle, episode.episodeTitle, episode.episodeId, api_server }, + keep_open = false, + selectable = true, + }) + end + + if uosc_available then + footnote = mp.get_property("filename") + update_menu_uosc(menu_type, menu_title, items, footnote) + elseif input_loaded then + show_message("", 0) + input.terminate() + mp.add_timeout(0.1, function() + open_menu_select(items) + end) + end + end) + active_request_type = menu_type end -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 - open_input_menu_get() + 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 "(未屏蔽)") + 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 - action = "delete" - text = string.format(" [%02d] %s [来源:用户添加] ", serial, url) + 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 - - local style = (tonumber(select_num) == serial) and - "{\\c&HFFDE7F&\\b1}" or (action == "unblock" and "{\\c&H4C4CC3&\\b0}" or "{\\c&HCCCCCC&\\b0}") - - deal_value[serial] = {value = url, action = action} - table.insert(menu_log, {text = text, style = style}) end end @@ -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 - open_add_menu_get() + 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") diff --git a/scripts/uosc_danmaku/modules/options.lua b/scripts/uosc_danmaku/modules/options.lua index 370e741..d5f93a0 100644 --- a/scripts/uosc_danmaku/modules/options.lua +++ b/scripts/uosc_danmaku/modules/options.lua @@ -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.icu,https://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", diff --git a/scripts/uosc_danmaku/modules/parse.lua b/scripts/uosc_danmaku/modules/parse.lua index ad28c40..2ad5eae 100644 --- a/scripts/uosc_danmaku/modules/parse.lua +++ b/scripts/uosc_danmaku/modules/parse.lua @@ -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(""", "\"") :gsub("'", "'") @@ -154,62 +181,103 @@ 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 - table.sort(group, function(a, b) return a.time < b.time end) + for _, group in ipairs(final_groups) do + table.sort(group, function(a, b) return a.time < b.time end) - local i = 1 - while i <= #group do - local base = group[i] - local times = { base.time } - local count = 1 - local j = i + 1 + local i = 1 + while i <= #group do + local base = group[i] + local times = { base.time } + local count = 1 + local j = i + 1 - while j <= #group and abs(group[j].time - base.time) <= threshold do - times[#times+1] = group[j].time - count = count + 1 - j = j + 1 - end + while j <= #group and abs(group[j].time - base.time) <= threshold do + times[#times+1] = group[j].time + count = count + 1 + j = j + 1 + end - local same_time = true - for k = 2, #times do - if times[k] ~= times[1] then - same_time = false - break - end - end - - local danmaku = { - time = base.time, - type = base.type, - size = base.size, - color = base.color, - text = base.text, - source = base.source, - orig_time = base.orig_time, - } - if count > 2 or not same_time then - danmaku.text = danmaku.text .. string.format("x%d", count) - end - - table.insert(merged, danmaku) - i = j + local same_time = true + for k = 2, #times do + if times[k] ~= times[1] then + same_time = false + break end end + + local danmaku = { + time = base.time, + type = base.type, + size = base.size, + color = base.color, + text = base.text, + source = base.source, + orig_time = base.orig_time, + } + if count > 2 or not same_time then + danmaku.text = danmaku.text .. string.format("x%d", count) + end + + table.insert(merged, danmaku) + i = j end end @@ -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(']*%f[^%s]p="([^"]+)"[^>]*>([^<]+)') 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) diff --git a/scripts/uosc_danmaku/modules/render.lua b/scripts/uosc_danmaku/modules/render.lua index 2088859..d627e06 100644 --- a/scripts/uosc_danmaku/modules/render.lua +++ b/scripts/uosc_danmaku/modules/render.lua @@ -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) \ No newline at end of file diff --git a/scripts/uosc_danmaku/modules/utils.lua b/scripts/uosc_danmaku/modules/utils.lua index 46fc31b..fe260e8 100644 --- a/scripts/uosc_danmaku/modules/utils.lua +++ b/scripts/uosc_danmaku/modules/utils.lua @@ -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 @@ -752,4 +827,166 @@ function call_cmd_async(args, callback) return function() 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 \ No newline at end of file diff --git a/scripts/uosc_danmaku/sites/bahamut.lua b/scripts/uosc_danmaku/sites/bahamut.lua new file mode 100644 index 0000000..5a8ebd4 --- /dev/null +++ b/scripts/uosc_danmaku/sites/bahamut.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc_danmaku/sites/bilibili.lua b/scripts/uosc_danmaku/sites/bilibili.lua new file mode 100644 index 0000000..d96aa15 --- /dev/null +++ b/scripts/uosc_danmaku/sites/bilibili.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc_danmaku/sites/iqiyi.lua b/scripts/uosc_danmaku/sites/iqiyi.lua new file mode 100644 index 0000000..b8e61b1 --- /dev/null +++ b/scripts/uosc_danmaku/sites/iqiyi.lua @@ -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..'>(.-)') 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 \ No newline at end of file diff --git a/scripts/uosc_danmaku/sites/mgtv.lua b/scripts/uosc_danmaku/sites/mgtv.lua new file mode 100644 index 0000000..5d00f1b --- /dev/null +++ b/scripts/uosc_danmaku/sites/mgtv.lua @@ -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//.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 \ No newline at end of file diff --git a/scripts/uosc_danmaku/sites/tencentvideo.lua b/scripts/uosc_danmaku/sites/tencentvideo.lua new file mode 100644 index 0000000..afd9503 --- /dev/null +++ b/scripts/uosc_danmaku/sites/tencentvideo.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc_danmaku/sites/youku.lua b/scripts/uosc_danmaku/sites/youku.lua new file mode 100644 index 0000000..f2ed1e6 --- /dev/null +++ b/scripts/uosc_danmaku/sites/youku.lua @@ -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 \ No newline at end of file