├── LICENSE ├── README.md ├── README_zh.md ├── adevice-list.lua ├── auto-save-state.lua ├── chapter-list.lua ├── chapter-make-read.lua ├── drcbox.lua ├── edition-list.lua ├── hdr-mode.lua ├── history-bookmark.lua ├── mpv-animated.lua ├── mpv-torrserver.lua ├── open_dialog.lua ├── skiptosilence.lua ├── slicing_copy.lua ├── sponsorblock_minimal.lua ├── sub_export.lua ├── track-list.lua └── trackselect.lua /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 dyphire 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mpv-scipts([中文介绍](README_zh.md)) 2 | 3 | ## adevice-list.lua 4 | 5 | Interractive audio-device list menu on OSD. Requires that [scroll-list.lua](https://github.com/CogentRedTester/mpv-scroll-list) be installed. 6 | 7 | ## auto-save-state.lua 8 | 9 | Periodically saves progress with write-watch-later-config, and also cleans up the watch later data after the file is finished playing (so playlists may continue at the correct file). 10 | 11 | ## [clipboard.lua](https://github.com/dyphire/mpv-clipboard/blob/dev/clipboard.lua) 12 | 13 | Provides generic low-level clipboard commands for users and script writers. 14 | 15 | Requires `powershell` on Windows,`pbcopy`/`pbpaste` on MacOS, `xclip` on X11, and `wl-copy`/`wl-paste` on Wayland. 16 | 17 | Modified from: [CogentRedTester/mpv-clipboard](https://github.com/CogentRedTester/mpv-clipboard) 18 | 19 | ## chapter-list.lua 20 | 21 | Interractive chapter-list menu on OSD. Requires that [scroll-list.lua](https://github.com/CogentRedTester/mpv-scroll-list) be installed. 22 | 23 | Modified from: [CogentRedTester/mpv-scroll-list/chapter-list.lua](https://github.com/CogentRedTester/mpv-scroll-list/blob/master/examples/chapter-list.lua) 24 | 25 | ## chapter-make-read.lua 26 | 27 | Try to load an external `.chp` "chapter sidecar file" when opening a video. (Analog to an external subtitle file like .SRT). 28 | 29 | Configuration: 30 | 31 | - `video.mp4.chp` is the chapter file for `video.mp4` with the option `basename_with_ext = true` in `chapter-make-read.lua` 32 | - `video.chp` for `video.mp4` with the option `basename_with_ext = false` in `chapter-make-read.lua` 33 | - You could change all parameters of the script by editing your `script-opts/chapter_make_read.conf`, see: [chapter-make-read.lua](chapter-make-read.lua) 34 | - Timestamps for external chapter files should use the 12-bit format of `hh:mm:ss.sss`. 35 | - The external chapter files encoding should be UTF-8 and the linebreak should be Unix(LF). 36 | 37 | The script supports external chapter file content in the following formats: 38 | 39 | ``` 40 | 00:00:00.000 A part 41 | 00:00:40.312 OP 42 | 00:02:00.873 B part 43 | 00:10:44.269 C part 44 | 00:22:40.146 ED 45 | ``` 46 | 47 | ``` 48 | 00:00:00.000 49 | 00:00:40.312 50 | 00:02:00.873 51 | 00:10:44.269 52 | 00:22:40.146 53 | ``` 54 | 55 | ``` 56 | 0:00:00 A part 57 | 0:00:40 OP 58 | 0:02:00 B part 59 | 0:10:44 C part 60 | 0:22:40 ED 61 | ``` 62 | 63 | ``` 64 | 0:00:00.000,Title1 65 | 0:17:02.148,Title2 66 | 0:28:10.114,Title3 67 | ``` 68 | 69 | OGM format (`ogm`) 70 | 71 | ``` 72 | CHAPTER01=00:00:00.000 73 | CHAPTER01NAME=Intro 74 | CHAPTER02=00:02:30.000 75 | CHAPTER02NAME=Baby prepares to rock 76 | CHAPTER03=00:02:42.300 77 | CHAPTER03NAME=Baby rocks the house 78 | ``` 79 | 80 | MediaInfo format (`mediainfo`) 81 | 82 | ``` 83 | Menu 84 | 00:00:00.000 : en:Contours 85 | 00:02:49.624 : en:From the Sea 86 | 00:08:41.374 : en:Bread and Wine 87 | 00:12:18.041 : en:Faceless 88 | ``` 89 | 90 | This script also supports manually load/refresh,marks,edits,remove and creates external chapter files(It can also be used to export the existing chapter information of the playback file). Usage: 91 | 92 | Customize the following keybinds in your `input.conf`. 93 | 94 | ```ini 95 | # Manually load/refresh chapter file 96 | key script-message-to chapter_make_read load_chapter 97 | # Mark chapters 98 | key script-message-to chapter_make_read create_chapter 99 | # Remove current chapter 100 | key script-message-to chapter_make_read remove_chapter 101 | # Edit existing chapter's title 102 | key script-message-to chapter_make_read edit_chapter 103 | # Export chp file 104 | key script-message-to chapter_make_read write_chapter chp 105 | # Export ogm file 106 | key script-message-to chapter_make_read write_chapter ogm 107 | ``` 108 | 109 | - if you want to have the ability to name/rename chapters, the minimum requirement for the mpv version is 0.38.0, or choose to install the [mpv-user-input](https://github.com/CogentRedTester/mpv-user-input) 110 | - Some recommendations 111 | 112 | - another chapters script: [mar04/chapters_for_mpv](https://github.com/mar04/chapters_for_mpv) 113 | - chapter format conversion tool:https://github.com/fireattack/chapter_converter 114 | 115 | ## [chapterskip.lua](https://github.com/dyphire/chapterskip/blob/dev/chapterskip.lua) 116 | 117 | Automatically skips chapters based on title. 118 | 119 | Modified from [po5/chapterskip](https://github.com/po5/chapterskip) 120 | 121 | ## drcbox.lua 122 | 123 | Dynamic Audio Normalizer filter with visual feedback. 124 | 125 | Modified from https://gist.github.com/richardpl/0c8011dc23d7ac7b7831b2e6d680114f 126 | 127 | ## edition-list.lua 128 | 129 | Interractive edition-list menu on OSD. Requires that [scroll-list.lua](https://github.com/CogentRedTester/mpv-scroll-list) be installed. 130 | 131 | - Prints a message on the OSD if editions are found in the file, and temporarily switches the osd-playback-message to the editions-list property when switching. This makes it easier to tell the number and names while navigating editions. 132 | 133 | Modified from [CogentRedTester/mpv-scripts/editions-notification.lua](https://github.com/CogentRedTester/mpv-scripts/blob/master/editions-notification.lua) 134 | 135 | ## hdr-mode.lua 136 | 137 | Automatically switches the display's SDR and HDR modes for HDR passthrough based on the content of the video being played by the mpv 138 | 139 | Only works on Windows 10 and later systems 140 | 141 | Requires for use with [mpv-display-plugin](https://github.com/dyphire/mpv-display-plugin). 142 | 143 | ## history-bookmark.lua 144 | 145 | This script helps you to create a history file `.mpv.history` in the specified path. The next time you want to continue to watch it, you can open any videos in the folder. The script will lead you to the video played last time. 146 | 147 | Modified from [yuukidach/history-bookmark.lua](https://github.com/yuukidach/mpv-scripts/blob/master/history_bookmark.lua) 148 | 149 | ## mpv-animated.lua 150 | 151 | Creates high quality animated webp/gif using mpv hotkeys. Requires that FFmpeg be installed. 152 | 153 | Modified from [DonCanjas/mpv-webp-generator](https://github.com/DonCanjas/mpv-webp-generator) 154 | 155 | ## mpv-torrserver.lua 156 | 157 | sends torrent info to [TorrServer](https://github.com/YouROK/TorrServer) and gets playlist. Supports torrent files and magnet links. Requires curl and [TorrServer](https://github.com/YouROK/TorrServer). 158 | 159 | ### Usage 160 | 161 | Drag & Drop torrent into mpv, or: 162 | 163 | ```sh 164 | mpv 165 | ``` 166 | 167 | Modified from [kritma/mpv-torrserver](https://github.com/kritma/mpv-torrserver) 168 | 169 | ## open_dialog.lua 170 | 171 | Load folder/files/iso/clipboard (support url)/other subtitles/other audio tracks/other video tracks. (Windows) 172 | 173 | **Note**: Windows 10/11 users are recommended to use it with PowerShell 7. Advantages: better performance, more modern dialog styles. 174 | 175 | - Official Installation Command: `winget install Microsoft.PowerShell` 176 | 177 | Inspiration from [rossy/mpv-open-file-dialog](https://github.com/rossy/mpv-open-file-dialog), [tsl0922/dialog.lua](https://github.com/tsl0922/mpv-menu-plugin/blob/main/lua/dialog.lua) 178 | 179 | ## [playlistmanager.lua](https://github.com/dyphire/mpv-playlistmanager) 180 | 181 | Mpv lua script to create and manage playlists 182 | 183 | Modified from [jonniek/mpv-playlistmanager](https://github.com/jonniek/mpv-playlistmanager) 184 | 185 | ## [recent.lua](https://github.com/dyphire/recent) 186 | 187 | Logs played files to a history log file with an interactive 'recently played' menu that reads from the log. Allows for automatic or manual logging if you want a file bookmark menu instead. 188 | 189 | Modified from [hacel/recent](https://github.com/hacel/recent) 190 | 191 | ## skiptosilence.lua 192 | 193 | This script skips to the next silence in the file. The intended use for this is to skip until the end of an opening or ending sequence, at which point there's often a short period of silence. 194 | 195 | Modified from [detuur-mpv-scripts/skiptosilence.lua](https://github.com/Eisa01/detuur-mpv-scripts/blob/master/skiptosilence.lua) 196 | 197 | ## slicing_copy.lua 198 | 199 | This script is for mpv to cut fragments of the video.. Requires that FFmpeg be installed. 200 | 201 | Modified from [snylonue/mpv_slicing_copy](https://github.com/snylonue/mpv_slicing_copy) 202 | 203 | ## sponsorblock_minimal.lua 204 | 205 | This script skip/mute sponsored segments of YouTube and bilibili videos 206 | 207 | using data from https://github.com/ajayyy/SponsorBlock and https://github.com/hanydd/BilibiliSponsorBlock 208 | 209 | Modified from [jouni/mpv_sponsorblock_minimal](https://codeberg.org/jouni/mpv_sponsorblock_minimal) 210 | 211 | ## sub_export.lua 212 | 213 | Export the internal subtitles of the playback file. Requires that FFmpeg be installed. 214 | 215 | The script support subtitles in srt, ass, and sup formats. 216 | 217 | Modified from [kelciour/mpv-scripts/sub-export.lua](https://github.com/kelciour/mpv-scripts/blob/master/sub-export.lua) 218 | 219 | ## [sub-fastwhisper.lua](https://github.com/dyphire/mpv-sub-fastwhisper) 220 | 221 | Generate srt subtitles through voice transcription using faster-whisper 222 | 223 | ## track-list.lua 224 | 225 | Interractive track-list menu on OSD. Requires that [scroll-list.lua](https://github.com/CogentRedTester/mpv-scroll-list) be installed. 226 | 227 | ## trackselect.lua 228 | 229 | Automatically select your preferred tracks based on title, because --alang isn't smart enough. 230 | 231 | Modified from [po5/trackselect](https://github.com/po5/trackselect) 232 | 233 | ## [trakt-scrobble.lua](https://github.com/dyphire/trakt-scrobble) 234 | 235 | A MPV script that checks in your movies and shows with Trakt.tv 236 | 237 | Modified from [LiTO773/trakt-mpv](https://github.com/LiTO773/trakt-mpv) 238 | 239 | ## [uosc.lua](https://github.com/dyphire/uosc) 240 | 241 | Feature-rich minimalist proximity-based UI for [MPV player](https://mpv.io/). 242 | 243 | Modified from [tomasklaen/uosc](https://github.com/tomasklaen/uosc) 244 | -------------------------------------------------------------------------------- /README_zh.md: -------------------------------------------------------------------------------- 1 | # mpv-scipts 2 | 3 | ## adevice-list.lua 4 | 5 | OSD 交互式音频设备菜单,依赖 [scroll-list.lua](https://github.com/CogentRedTester/mpv-scroll-list) 6 | 7 | ## chapter-list.lua 8 | 9 | OSD 交互式章节菜单,依赖 [scroll-list.lua](https://github.com/CogentRedTester/mpv-scroll-list) 10 | 11 | 修改自 [CogentRedTester/mpv-scroll-list/chapter-list.lua](https://github.com/CogentRedTester/mpv-scroll-list/blob/master/examples/chapter-list.lua) 12 | 13 | ## chapter-make-read.lua 14 | 15 | 实现自动读取并加载视频文件同目录或指定的子目录(默认:`chapters`)下的同名 + 标识扩展的外部章节文件,默认扩展名:`.chp` 16 | 17 | 示例:`video.mp4.chp` 用于 `video.mp4`. 18 | 19 | - 子目录和标识扩展名的更改可在`script-opts`下的脚本同名配置文件`chapter_make_read.conf`中设置 20 | - 外部章节文件的时间戳尽可能使用`hh:mm:ss.sss`的 12 位格式 21 | - 外部章节文件的文件编码应为 UTF-8,换行符为 Unix(LF) 22 | 23 | 以下几种外部章节文件的内容格式均被该脚本支持 24 | 25 | ``` 26 | 00:00:00.000 A part 27 | 00:00:40.312 OP 28 | 00:02:00.873 B part 29 | 00:10:44.269 C part 30 | 00:22:40.146 ED 31 | ``` 32 | 33 | ``` 34 | 00:00:00.000 35 | 00:00:40.312 36 | 00:02:00.873 37 | 00:10:44.269 38 | 00:22:40.146 39 | ``` 40 | 41 | ``` 42 | 0:00:00 A part 43 | 0:00:40 OP 44 | 0:02:00 B part 45 | 0:10:44 C part 46 | 0:22:40 ED 47 | ``` 48 | 49 | ``` 50 | 0:00:00.000,Title1 51 | 0:17:02.148,Title2 52 | 0:28:10.114,Title3 53 | ``` 54 | OGM format (`ogm`) 55 | 56 | ``` 57 | CHAPTER01=00:00:00.000 58 | CHAPTER01NAME=Intro 59 | CHAPTER02=00:02:30.000 60 | CHAPTER02NAME=Baby prepares to rock 61 | CHAPTER03=00:02:42.300 62 | CHAPTER03NAME=Baby rocks the house 63 | ``` 64 | MediaInfo format (`mediainfo`) 65 | 66 | ``` 67 | Menu 68 | 00:00:00.000 : en:Contours 69 | 00:02:49.624 : en:From the Sea 70 | 00:08:41.374 : en:Bread and Wine 71 | 00:12:18.041 : en:Faceless 72 | ``` 73 | 74 | 该脚本同时支持手动加载/刷新外部章节文件、标记新章节、编辑当前章节标题、删除当前章节和创建外部章节文件(也可用于导出已有的章节信息),用法如下: 75 | 76 | 在 mpv 的 input.conf 中绑定以下功能键位 77 | 78 | ```ini 79 | # 手动加载/刷新外部章节文件 80 | key script-message-to chapter_make_read load_chapter 81 | # 标记章节时间 82 | key script-message-to chapter_make_read create_chapter 83 | # 删除当前章节 84 | key script-message-to chapter_make_read remove_chapter 85 | # 编辑当前章节标题 86 | key script-message-to chapter_make_read edit_chapter 87 | # 创建 mpv 可读的外部章节文件 88 | key script-message-to chapter_make_read write_chapter chp 89 | # 创建 mpv 可读的 ogm 格式章节文件 90 | key script-message-to chapter_make_read write_chapter ogm 91 | ``` 92 | - 如果你想能够命名/重命名章节,mpv 版本的最低要求为 0.39.0 ,或者选择安装 93 | 94 | 95 | - 其他推荐 96 | - 另一个类似的 mpv 章节脚本:[mar04/chapters_for_mpv](https://github.com/mar04/chapters_for_mpv) 97 | - 章节格式转换工具:https://github.com/fireattack/chapter_converter 98 | ## drcbox.lua 99 | 100 | 动态调节各通道音增益的 dynnorm 滤镜菜单脚本 101 | 102 | 修改自 https://gist.github.com/richardpl/0c8011dc23d7ac7b7831b2e6d680114f 103 | 104 | ## edition-list.lua 105 | 106 | OSD 交互式 edition 菜单,如果检测到播放文件存在多个 edition 则在 OSD 上提示。依赖 [scroll-list.lua](https://github.com/CogentRedTester/mpv-scroll-list) 107 | 108 | 修改自 [CogentRedTester/mpv-scripts/editions-notification.lua](https://github.com/CogentRedTester/mpv-scripts/blob/master/editions-notification.lua) 109 | 110 | 111 | ## mpv-animated.lua 112 | 113 | 使用 mpv 热键创建高质量的 webp/gif 动图,基于`ffmpeg` 114 | 115 | 修改自 [DonCanjas/mpv-webp-generator](https://github.com/DonCanjas/mpv-webp-generator) 116 | 117 | ## open_dialog.lua 118 | 119 | 快捷键载入文件夹/文件/ISO 文件/剪贴板(支持 URL)/其他字幕或音轨或视频轨(Windows) 120 | 121 | **友情提醒**:Windows 10/11 用户建议配合 PowerShell 7 使用,优点:更好的性能,更现代化的对话框样式 122 | 123 | - 官方安装命令:`winget install Microsoft.PowerShell` 124 | 125 | 灵感来自:[rossy/mpv-open-file-dialog](https://github.com/rossy/mpv-open-file-dialog), [tsl0922/dialog.lua](https://github.com/tsl0922/mpv-menu-plugin/blob/main/lua/dialog.lua) 126 | 127 | ## sub_export.lua 128 | 129 | 导出当前内封字幕,依赖 ffmpeg,脚本支持 srt、ass 和 sup 格式的字幕 130 | 131 | 修改自 [kelciour/mpv-scripts/sub-export.lua](https://github.com/kelciour/mpv-scripts/blob/master/sub-export.lua) 132 | 133 | ## track-list.lua 134 | 135 | OSD 交互式轨道菜单,依赖 [scroll-list.lua](https://github.com/CogentRedTester/mpv-scroll-list) 136 | 137 | 138 | ## 更多 mpv 实用脚本请移步 [dyphire/mpv-config/scripts](https://github.com/dyphire/mpv-config/tree/master/scripts) 139 | -------------------------------------------------------------------------------- /adevice-list.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | * adevice-list.lua v.2024-11-11 3 | * 4 | * AUTHORS: dyphire 5 | * License: MIT 6 | * link: https://github.com/dyphire/mpv-scripts 7 | 8 | This script implements an interractive audio-device list 9 | Usage: add bindings to input.conf 10 | -- key script-message-to adevice_list toggle-adevice-browser 11 | 12 | This script needs to be used with scroll-list.lua 13 | https://github.com/CogentRedTester/mpv-scroll-list 14 | ]] 15 | 16 | local mp = require 'mp' 17 | local opts = require("mp.options") 18 | 19 | local o = { 20 | -- header of the list 21 | -- %cursor% and %total% to be used to display the cursor position and the total number of lists 22 | header = "Adevice List [%cursor%/%total%]\\N ------------------------------------", 23 | --list ass style overrides inside curly brackets 24 | --these styles will be used for the whole list. so you need to reset them for every line 25 | --read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags 26 | global_style = [[]], 27 | header_style = [[{\q2\fs30\c&00ccff&}]], 28 | list_style = [[{\q2\fs20\c&Hffffff&}]], 29 | wrapper_style = [[{\c&00ccff&\fs16}]], 30 | cursor_style = [[{\c&00ccff&}]], 31 | selected_style = [[{\c&Hfce788&}]], 32 | active_style = [[{\c&H33ff66&}]], 33 | cursor = [[➤\h]], 34 | indent = [[\h\h\h]], 35 | --amount of entries to show before slicing. Optimal value depends on font/video size etc. 36 | num_entries = 16, 37 | -- wrap the cursor around the top and bottom of the list 38 | wrap = true, 39 | -- set dynamic keybinds to bind when the list is open 40 | key_move_begin = "HOME", 41 | key_move_end = "END", 42 | key_move_pageup = "PGUP", 43 | key_move_pagedown = "PGDWN", 44 | key_scroll_down = "DOWN WHEEL_DOWN", 45 | key_scroll_up = "UP WHEEL_UP", 46 | key_open_adevice = "ENTER MBTN_LEFT", 47 | key_close_browser = "ESC MBTN_RIGHT", 48 | } 49 | 50 | opts.read_options(o) 51 | 52 | --adding the source directory to the package path and loading the module 53 | package.path = mp.command_native({"expand-path", "~~/script-modules/?.lua;"}) .. package.path 54 | local list = require "scroll-list" 55 | 56 | --modifying the list settings 57 | list.header = o.header 58 | list.cursor = o.cursor 59 | list.indent = o.indent 60 | list.wrap = o.wrap 61 | list.num_entries = o.num_entries 62 | list.global_style = o.global_style 63 | list.header_style = o.header_style 64 | list.list_style = o.list_style 65 | list.wrapper_style = o.wrapper_style 66 | list.cursor_style = o.cursor_style 67 | list.selected_style = o.selected_style 68 | 69 | --escape header specifies the format 70 | --display the cursor position and the total number of lists in the header 71 | function list:format_header_string(str) 72 | if #list.list > 0 then 73 | str = str:gsub("%%(%a+)%%", { cursor = list.selected, total = #list.list }) 74 | else str = str:gsub("%[.*%]", "") end 75 | return str 76 | end 77 | 78 | --update the list when the current audio-device changes 79 | local function adevice_list() 80 | mp.observe_property('audio-device-list', 'native', function(_, adevice_list) 81 | mp.observe_property('audio-device', 'string', function(_, current_device) 82 | list.list = {} 83 | if adevice_list == nil then 84 | list:update() 85 | return 86 | end 87 | if list.selected > 0 and string.find(adevice_list[list.selected].name, current_device) == nil then 88 | list.selected = 0 89 | end 90 | for i = 1, #adevice_list do 91 | local item = {} 92 | if current_device ~= nil then 93 | if adevice_list[i].name == current_device then 94 | list.selected = i 95 | end 96 | end 97 | if (i == list.selected) then 98 | item.style = o.active_style 99 | item.ass = "■ " .. list.ass_escape(adevice_list[i].description) 100 | else 101 | item.ass = "□ " .. list.ass_escape(adevice_list[i].description) 102 | end 103 | list.list[i] = item 104 | end 105 | list:update() 106 | end) 107 | end) 108 | list:toggle() 109 | end 110 | 111 | --open to the selected audio-device 112 | local function open_adevice() 113 | local adevice_list = mp.get_property_native('audio-device-list', {}) 114 | if list.list[list.selected] then 115 | mp.set_property("audio-device", adevice_list[list.selected].name) 116 | end 117 | end 118 | 119 | --dynamic keybinds to bind when the list is open 120 | list.keybinds = {} 121 | 122 | local function add_keys(keys, name, fn, flags) 123 | local i = 1 124 | for key in keys:gmatch("%S+") do 125 | table.insert(list.keybinds, { key, name .. i, fn, flags }) 126 | i = i + 1 127 | end 128 | end 129 | 130 | add_keys(o.key_scroll_down, 'scroll_down', function() list:scroll_down() end, { repeatable = true }) 131 | add_keys(o.key_scroll_up, 'scroll_up', function() list:scroll_up() end, { repeatable = true }) 132 | add_keys(o.key_move_pageup, 'move_pageup', function() list:move_pageup() end, {}) 133 | add_keys(o.key_move_pagedown, 'move_pagedown', function() list:move_pagedown() end, {}) 134 | add_keys(o.key_move_begin, 'move_begin', function() list:move_begin() end, {}) 135 | add_keys(o.key_move_end, 'move_end', function() list:move_end() end, {}) 136 | add_keys(o.key_open_adevice, 'open_adevice', open_adevice, {}) 137 | add_keys(o.key_close_browser, 'close_browser', function() list:close() end, {}) 138 | 139 | mp.register_script_message("toggle-adevice-browser", adevice_list) 140 | 141 | mp.register_event('end-file', function() 142 | list:close() 143 | mp.unobserve_property(adevice_list) 144 | end) -------------------------------------------------------------------------------- /auto-save-state.lua: -------------------------------------------------------------------------------- 1 | -- Runs write-watch-later-config periodically 2 | 3 | local options = require 'mp.options' 4 | local msg = require 'mp.msg' 5 | 6 | o = { 7 | save_interval = 60, 8 | percent_pos = 99, 9 | } 10 | options.read_options(o) 11 | 12 | local can_delete = true 13 | local can_save = true 14 | local path = nil -- only set after file success load, reset to nil when file unload. 15 | 16 | local function reset() 17 | path = nil 18 | end 19 | 20 | -- set vars when file success load 21 | local function init() 22 | path = mp.get_property("path") 23 | end 24 | 25 | local function save() 26 | if not can_save then return end 27 | local watch_later_list = mp.get_property("watch-later-options", {}) 28 | if mp.get_property_bool("save-position-on-quit") then 29 | msg.debug("saving state") 30 | if not watch_later_list:find("start") then 31 | mp.commandv("change-list", "watch-later-options", "append", "start") 32 | end 33 | mp.command("write-watch-later-config") 34 | end 35 | end 36 | 37 | local function save_if_pause(_, pause) 38 | if pause then save() end 39 | end 40 | 41 | local function pause_timer_while_paused(_, pause) 42 | if pause then timer:stop() else timer:resume() end 43 | end 44 | 45 | -- save watch-later-config when file unloading 46 | local function save_or_delete() 47 | if not can_delete then return end 48 | local eof = mp.get_property_bool("eof-reached") 49 | local percent_pos = mp.get_property_number("percent-pos") 50 | if eof or percent_pos and (percent_pos == 0 or percent_pos >= o.percent_pos) then 51 | can_delete = true 52 | if path ~= nil then 53 | msg.debug("deleting state: percent_pos=0 or eof") 54 | mp.commandv("delete-watch-later-config", path) 55 | end 56 | elseif path ~= nil then 57 | save() 58 | end 59 | reset() 60 | end 61 | 62 | mp.register_script_message("skip-delete-state", function() can_delete = false end) 63 | 64 | timer = mp.add_periodic_timer(o.save_interval, save) 65 | mp.observe_property("pause", "bool", pause_timer_while_paused) 66 | 67 | mp.observe_property("pause", "bool", save_if_pause) 68 | 69 | mp.register_event("file-loaded", init) 70 | mp.add_hook("on_unload", 50, save_or_delete) -- after mpv saving state -------------------------------------------------------------------------------- /chapter-list.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This script implements an interractive chapter list 3 | Usage: add bindings to input.conf 4 | -- key script-message-to chapter_list toggle-chapter-browser 5 | 6 | This script was written as an example for the mpv-scroll-list api 7 | https://github.com/CogentRedTester/mpv-scroll-list 8 | ]] 9 | 10 | local msg = require 'mp.msg' 11 | local opts = require("mp.options") 12 | 13 | local o = { 14 | -- header of the list 15 | -- %cursor% and %total% to be used to display the cursor position and the total number of lists 16 | header = "Chapter List [%cursor%/%total%]\\N ------------------------------------", 17 | --list ass style overrides inside curly brackets 18 | --these styles will be used for the whole list. so you need to reset them for every line 19 | --read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags 20 | global_style = [[]], 21 | header_style = [[{\q2\fs30\c&00ccff&}]], 22 | list_style = [[{\q2\fs20\c&Hffffff&}]], 23 | wrapper_style = [[{\c&00ccff&\fs16}]], 24 | cursor_style = [[{\c&00ccff&}]], 25 | selected_style = [[{\c&Hfce788&}]], 26 | active_style = [[{\c&H33ff66&}]], 27 | cursor = [[➤\h]], 28 | indent = [[\h\h\h]], 29 | --amount of entries to show before slicing. Optimal value depends on font/video size etc. 30 | num_entries = 16, 31 | --slice long filenames, and how many chars to show 32 | max_title_length = 100, 33 | -- wrap the cursor around the top and bottom of the list 34 | wrap = true, 35 | -- set dynamic keybinds to bind when the list is open 36 | key_move_begin = "HOME", 37 | key_move_end = "END", 38 | key_move_pageup = "PGUP", 39 | key_move_pagedown = "PGDWN", 40 | key_scroll_down = "DOWN WHEEL_DOWN", 41 | key_scroll_up = "UP WHEEL_UP", 42 | key_open_chapter = "ENTER MBTN_LEFT", 43 | key_close_browser = "ESC MBTN_RIGHT", 44 | key_remove_chapter = "DEL BS", 45 | key_edit_chapter = "e E", 46 | -- pause the playback when editing for chapter title 47 | pause_on_input = true, 48 | } 49 | 50 | opts.read_options(o) 51 | 52 | local reset_curr = true 53 | local paused = false 54 | 55 | --adding the source directory to the package path and loading the module 56 | package.path = mp.command_native({"expand-path", "~~/script-modules/?.lua;"}) .. package.path 57 | local list = require "scroll-list" 58 | 59 | local input_loaded, input = pcall(require, "mp.input") 60 | -- Requires: https://github.com/CogentRedTester/mpv-user-input 61 | local user_input_loaded, user_input = pcall(require, "user-input-module") 62 | 63 | --modifying the list settings 64 | list.header = o.header 65 | list.cursor = o.cursor 66 | list.indent = o.indent 67 | list.wrap = o.wrap 68 | list.num_entries = o.num_entries 69 | list.global_style = o.global_style 70 | list.header_style = o.header_style 71 | list.list_style = o.list_style 72 | list.wrapper_style = o.wrapper_style 73 | list.cursor_style = o.cursor_style 74 | list.selected_style = o.selected_style 75 | 76 | --escape header specifies the format 77 | --display the cursor position and the total number of lists in the header 78 | function list:format_header_string(str) 79 | if #list.list > 0 then 80 | str = str:gsub("%%(%a+)%%", { cursor = list.selected, total = #list.list }) 81 | else str = str:gsub("%[.*%]", "") end 82 | return str 83 | end 84 | 85 | -- from http://lua-users.org/wiki/LuaUnicode 86 | local UTF8_PATTERN = '[%z\1-\127\194-\244][\128-\191]*' 87 | 88 | -- return a substring based on utf8 characters 89 | -- like string.sub, but negative index is not supported 90 | local function utf8_sub(s, i, j) 91 | if i > j then 92 | return s 93 | end 94 | 95 | local t = {} 96 | local idx = 1 97 | for char in s:gmatch(UTF8_PATTERN) do 98 | if i <= idx and idx <= j then 99 | local width = #char > 2 and 2 or 1 100 | idx = idx + width 101 | t[#t + 1] = char 102 | end 103 | end 104 | return table.concat(t) 105 | end 106 | 107 | --update the list when the current chapter changes 108 | local function chapter_list() 109 | mp.observe_property('chapter-list', 'native', function(_, chapter_list) 110 | mp.observe_property('chapter', 'number', function(_, curr_chapter) 111 | list.list = {} 112 | for i = 1, #chapter_list do 113 | local item = {} 114 | if curr_chapter and i == curr_chapter + 1 then 115 | if reset_curr then list.selected = i end 116 | item.style = o.active_style 117 | end 118 | 119 | local time = chapter_list[i].time 120 | local title = chapter_list[i].title 121 | if not title or title == '(unnamed)' or title == '' then 122 | title = "Chapter " .. string.format("%02.f", i) 123 | end 124 | local title_clip = utf8_sub(title, 1, o.max_title_length) 125 | if title ~= title_clip then 126 | title = title_clip .. "..." 127 | end 128 | if time < 0 then time = 0 129 | else time = math.floor(time) end 130 | item.ass = string.format("[%02d:%02d:%02d]", math.floor(time / 60 / 60), math.floor(time / 60) % 60, time % 60) 131 | item.ass = item.ass .. '\\h\\h\\h' .. list.ass_escape(title) 132 | list.list[i] = item 133 | end 134 | list:update() 135 | end) 136 | end) 137 | list:toggle() 138 | end 139 | 140 | local function change_chapter_list(chapter_tltle, chapter_index) 141 | local chapter_list = mp.get_property_native("chapter-list") 142 | 143 | if chapter_index > mp.get_property_number("chapter-list/count") then 144 | msg.warn("can't set chapter title") 145 | return 146 | end 147 | 148 | chapter_list[chapter_index].title = chapter_tltle 149 | mp.set_property_native("chapter-list", chapter_list) 150 | end 151 | 152 | local function change_title_callback(user_input, err, chapter_index) 153 | if user_input == nil or err ~= nil then 154 | if paused then return elseif o.pause_on_input then mp.set_property_native("pause", false) end 155 | msg.warn("no chapter title provided:", err) 156 | return 157 | end 158 | change_chapter_list(user_input, chapter_index) 159 | if paused then return elseif o.pause_on_input then mp.set_property_native("pause", false) end 160 | end 161 | 162 | local function input_title(default_input, cursor_pos, chapter_index) 163 | input.get({ 164 | prompt = 'Chapter title:', 165 | default_text = default_input, 166 | cursor_position = cursor_pos, 167 | submit = function(text) 168 | input.terminate() 169 | change_chapter_list(text, chapter_index) 170 | end, 171 | closed = function() 172 | if paused then return elseif o.pause_on_input then mp.set_property_native("pause", false) end 173 | end 174 | }) 175 | end 176 | 177 | --edit the selected chapter title 178 | local function edit_chapter() 179 | reset_curr = false 180 | local chapter_index = list.selected 181 | local chapter_list = mp.get_property_native("chapter-list", {}) 182 | 183 | if #chapter_list == 0 then 184 | msg.verbose("no chapter selected, nothing to edit") 185 | return 186 | end 187 | 188 | if not input_loaded and not user_input_loaded then 189 | msg.error("no mpv-user-input, can't get user input, install: https://github.com/CogentRedTester/mpv-user-input") 190 | return 191 | end 192 | 193 | local title = chapter_list[chapter_index].title 194 | if input_loaded then 195 | input_title(title, #title + 1, chapter_index) 196 | elseif user_input_loaded then 197 | -- ask user for chapter title 198 | -- (+1 because mpv indexes from 0, lua from 1) 199 | user_input.get_user_input(change_title_callback, { 200 | request_text = "Chapter title:", 201 | default_input = title, 202 | cursor_pos = #title + 1, 203 | }, chapter_index) 204 | end 205 | 206 | if o.pause_on_input then 207 | paused = mp.get_property_native("pause") 208 | mp.set_property_bool("pause", true) 209 | -- FIXME: for whatever reason osd gets hidden when we pause the 210 | -- playback like that, workaround to make input prompt appear 211 | -- right away without requiring mouse or keyboard action 212 | mp.osd_message(" ", 0.1) 213 | end 214 | end 215 | 216 | --remove the selected chapter 217 | local function remove_chapter() 218 | local chapter_list = mp.get_property_native("chapter-list") 219 | if list.selected > 0 then 220 | reset_curr = false 221 | table.remove(chapter_list, list.selected) 222 | msg.debug("removing chapter", list.selected) 223 | mp.set_property_native("chapter-list", chapter_list) 224 | end 225 | end 226 | 227 | --jump to the selected chapter 228 | local function open_chapter() 229 | if list.list[list.selected] then 230 | mp.set_property_number('chapter', list.selected - 1) 231 | end 232 | end 233 | 234 | --dynamic keybinds to bind when the list is open 235 | list.keybinds = {} 236 | 237 | local function add_keys(keys, name, fn, flags) 238 | local i = 1 239 | for key in keys:gmatch("%S+") do 240 | table.insert(list.keybinds, { key, name .. i, fn, flags }) 241 | i = i + 1 242 | end 243 | end 244 | 245 | add_keys(o.key_scroll_down, 'scroll_down', function() list:scroll_down() end, { repeatable = true }) 246 | add_keys(o.key_scroll_up, 'scroll_up', function() list:scroll_up() end, { repeatable = true }) 247 | add_keys(o.key_move_pageup, 'move_pageup', function() list:move_pageup() end, {}) 248 | add_keys(o.key_move_pagedown, 'move_pagedown', function() list:move_pagedown() end, {}) 249 | add_keys(o.key_move_begin, 'move_begin', function() list:move_begin() end, {}) 250 | add_keys(o.key_move_end, 'move_end', function() list:move_end() end, {}) 251 | add_keys(o.key_open_chapter, 'open_chapter', open_chapter, {}) 252 | add_keys(o.key_close_browser, 'close_browser', function() list:close() end, {}) 253 | add_keys(o.key_remove_chapter, 'remove_chapter', remove_chapter, {}) 254 | add_keys(o.key_edit_chapter, 'edit_chapter', edit_chapter, {}) 255 | 256 | mp.register_script_message("toggle-chapter-browser", chapter_list) 257 | 258 | if user_input_loaded and not input_loaded then 259 | mp.add_hook("on_unload", 50, function() user_input.cancel_user_input() end) 260 | end 261 | 262 | mp.register_event('end-file', function() 263 | list:close() 264 | mp.unobserve_property(chapter_list) 265 | end) -------------------------------------------------------------------------------- /chapter-make-read.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | * chapter-make-read.lua v.2025-03-01 3 | * 4 | * AUTHORS: dyphire 5 | * License: MIT 6 | * link: https://github.com/dyphire/mpv-scripts 7 | --]] 8 | 9 | --[[ 10 | Copyright (c) 2023 dyphire 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy 13 | of this software and associated documentation files (the "Software"), to deal 14 | in the Software without restriction, including without limitation the rights 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the Software is 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in all 20 | copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 28 | SOFTWARE. 29 | --]] 30 | 31 | -- Implementation read and automatically load the namesake external chapter file. 32 | -- The external chapter files should conform to the following formats. 33 | -- Note: The Timestamps should use the 12-bit format of 'hh:mm:ss.sss'. 34 | -- Note: The file encoding should be UTF-8 and the linebreak should be Unix(LF). 35 | -- Note: The script also supports reading OGM format and MediaInfo format in addition to the following formats. 36 | --[[ 37 | 00:00:00.000 A part 38 | 00:00:40.312 OP 39 | 00:02:00.873 B part 40 | 00:10:44.269 C part 41 | 00:22:40.146 ED 42 | --]] 43 | 44 | -- This script also supports manually load/refresh,marks,edits,remove and creates external chapter files, usage: 45 | -- Note: It can also be used to export the existing chapter information of the playback file. 46 | -- add bindings to input.conf: 47 | -- key script-message-to chapter_make_read load_chapter 48 | -- key script-message-to chapter_make_read create_chapter 49 | -- key script-message-to chapter_make_read edit_chapter 50 | -- key script-message-to chapter_make_read remove_chapter 51 | -- key script-message-to chapter_make_read write_chapter chp 52 | -- key script-message-to chapter_make_read write_chapter ogm 53 | 54 | local msg = require 'mp.msg' 55 | local utils = require 'mp.utils' 56 | local options = require "mp.options" 57 | 58 | local o = { 59 | autoload = true, 60 | autosave = false, 61 | force_overwrite = false, 62 | -- Specifies the extension of the external chapter file. 63 | chapter_file_ext = ".chp", 64 | -- Select whether the external chapter file needs to match the extension of the source file. 65 | basename_with_ext = true, 66 | -- Specifies the subpath of the same directory as the playback file as the external chapter file path. 67 | -- Note: The external chapter file is read from the subdirectory first. 68 | -- If the file does not exist, it will next be read from the same directory as the playback file. 69 | external_chapter_subpath = "chapters", 70 | -- save all chapter files in a single global directory 71 | global_chapters = false, 72 | global_chapters_dir = "~~/chapters", 73 | -- hash works only in global_chapters_dir 74 | hash = false, 75 | -- ask for title or leave it empty 76 | ask_for_title = true, 77 | -- placeholder when asking for title of a new chapter 78 | placeholder_title = "Chapter ", 79 | -- pause the playback when asking for chapter title 80 | pause_on_input = true, 81 | } 82 | 83 | options.read_options(o) 84 | 85 | local input_loaded, input = pcall(require, "mp.input") 86 | -- Requires: https://github.com/CogentRedTester/mpv-user-input 87 | local user_input_loaded, user_input = pcall(require, "user-input-module") 88 | 89 | local path = nil 90 | local dir = nil 91 | local fname = nil 92 | local chapter_fullpath = nil 93 | local all_chapters = {} 94 | local chapter_count = 0 95 | local chapters_modified = false 96 | local paused = false 97 | local protocol = false 98 | 99 | local function is_protocol(path) 100 | return type(path) == 'string' and (path:find('^%a[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= nil) 101 | end 102 | 103 | function url_decode(str) 104 | local function hex_to_char(x) 105 | return string.char(tonumber(x, 16)) 106 | end 107 | 108 | if str ~= nil then 109 | str = str:gsub('^%a[%a%d-_]+://', '') 110 | :gsub('^%a[%a%d-_]+:\\?', '') 111 | :gsub('%%(%x%x)', hex_to_char) 112 | if str:find('://localhost:?') then 113 | str = str:gsub('^.*/', '') 114 | end 115 | str = str:gsub('[\\/:%?]*', '') 116 | return str 117 | end 118 | end 119 | 120 | --create global_chapters_dir if it doesn't exist 121 | local global_chapters_dir = mp.command_native({ "expand-path", o.global_chapters_dir }) 122 | if global_chapters_dir and global_chapters_dir ~= '' then 123 | local meta = utils.file_info(global_chapters_dir) 124 | if not meta or not meta.is_dir then 125 | local is_windows = package.config:sub(1, 1) == "\\" 126 | local windows_args = { 'powershell', '-NoProfile', '-Command', 'mkdir', string.format("\"%s\"", global_chapters_dir) } 127 | local unix_args = { 'mkdir', '-p', global_chapters_dir } 128 | local args = is_windows and windows_args or unix_args 129 | local res = mp.command_native({ name = "subprocess", capture_stdout = true, playback_only = false, args = args }) 130 | if res.status ~= 0 then 131 | msg.error("Failed to create global_chapters_dir save directory " .. global_chapters_dir .. 132 | ". Error: " .. (res.error or "unknown")) 133 | return 134 | end 135 | end 136 | end 137 | 138 | local function read_chapter(func) 139 | local meta = utils.file_info(chapter_fullpath) 140 | if not meta or not meta.is_file then return end 141 | local f = io.open(chapter_fullpath, "r") 142 | if not f then return end 143 | local contents = {} 144 | for line in f:lines() do 145 | table.insert(contents, (func(line))) 146 | end 147 | f:close() 148 | return contents 149 | end 150 | 151 | local function read_chapter_table() 152 | local line_pos = 0 153 | return read_chapter(function(line) 154 | local h, m, s, t, n, l 155 | local thin_space = string.char(0xE2, 0x80, 0x89) 156 | local line = line:gsub(thin_space, " ") 157 | if line:match("^%d+:%d+:%d+") ~= nil then 158 | h, m, s = line:match("^(%d+):(%d+):(%d+[,%.]?%d+)") 159 | s = s:gsub(',', '.') 160 | t = h * 3600 + m * 60 + s 161 | if line:match("^%d+:%d+:%d+[,%.]?%d+[,%s].*") ~= nil then 162 | n = line:match("^%d+:%d+:%d+[,%.]?%d+[,%s](.*)") 163 | n = n:gsub(":%s%a?%a?:", "") 164 | :gsub("^%s*(.-)%s*$", "%1") 165 | end 166 | l = line 167 | line_pos = line_pos + 1 168 | elseif line:match("^%d+:%d+[,%.]?%d+[,%s].*") ~= nil then 169 | m, s = line:match("^(%d+):(%d+[,%.]?%d+)") 170 | s = s:gsub(',', '.') 171 | t = m * 60 + s 172 | if line:match("^%d+:%d+[,%.]?%d+[,%s].*") ~= nil then 173 | n = line:match("^%d+:%d+[,%.]?%d+[,%s](.*)") 174 | n = n:gsub(":%s%a?%a?:", "") 175 | :gsub("^%s*(.-)%s*$", "%1") 176 | end 177 | l = line 178 | line_pos = line_pos + 1 179 | elseif line:match("^CHAPTER%d+=%d+:%d+:%d+") ~= nil then 180 | h, m, s = line:match("^CHAPTER%d+=(%d+):(%d+):(%d+[,%.]?%d+)") 181 | s = s:gsub(',', '.') 182 | t = h * 3600 + m * 60 + s 183 | l = line 184 | line_pos = line_pos + 1 185 | elseif line:match("^CHAPTER%d+NAME=.*") ~= nil then 186 | n = line:gsub("^CHAPTER%d+NAME=", "") 187 | n = n:gsub("^%s*(.-)%s*$", "%1") 188 | l = line 189 | line_pos = line_pos + 1 190 | else 191 | return 192 | end 193 | return { found_title = n, found_time = t, found_line = l } 194 | end) 195 | end 196 | 197 | local function refresh_globals() 198 | path = mp.get_property("path") 199 | if path then 200 | protocol = is_protocol(path) 201 | dir = utils.split_path(path) 202 | end 203 | 204 | if protocol then 205 | fname = url_decode(mp.get_property("media-title")) 206 | elseif o.basename_with_ext then 207 | fname = mp.get_property("filename") 208 | else 209 | fname = mp.get_property("filename/no-ext") 210 | end 211 | 212 | all_chapters = mp.get_property_native("chapter-list") 213 | chapter_count = mp.get_property_number("chapter-list/count") 214 | end 215 | 216 | local function format_time(seconds) 217 | local result = "" 218 | local hours, mins, secs, msecs 219 | if seconds <= 0 then 220 | return "00:00:00.000"; 221 | else 222 | hours = string.format("%02.f", math.floor(seconds / 3600)) 223 | mins = string.format("%02.f", math.floor(seconds / 60 - (hours * 60))) 224 | secs = string.format("%02.f", math.floor(seconds - hours * 60 * 60 - mins * 60)) 225 | msecs = string.format("%03.f", seconds * 1000 - hours * 60 * 60 * 1000 - mins * 60 * 1000 - secs * 1000) 226 | result = hours .. ":" .. mins .. ":" .. secs .. "." .. msecs 227 | end 228 | return result 229 | end 230 | 231 | -- for unix use only 232 | -- returns a table of command path and varargs, or nil if command was not found 233 | local function command_exists(command, ...) 234 | msg.debug("looking for command:", command) 235 | -- msg.debug("args:", ) 236 | local process = mp.command_native({ 237 | name = "subprocess", 238 | capture_stdout = true, 239 | capture_stderr = true, 240 | playback_only = false, 241 | args = {"sh", "-c", "command -v -- " .. command} 242 | }) 243 | 244 | if process.status == 0 then 245 | local command_path = process.stdout:gsub("\n", "") 246 | msg.debug("command found:", command_path) 247 | return {command_path, ...} 248 | else 249 | msg.debug("command not found:", command) 250 | return nil 251 | end 252 | end 253 | 254 | -- returns md5 hash of the full path of the current media file 255 | local function hash(path) 256 | if path == nil then 257 | msg.debug("something is wrong with the path, can't get full_path, can't hash it") 258 | return 259 | end 260 | 261 | msg.debug("hashing:", path) 262 | 263 | local cmd = { 264 | name = 'subprocess', 265 | capture_stdout = true, 266 | playback_only = false, 267 | } 268 | 269 | local args = nil 270 | local is_unix = package.config:sub(1,1) == "/" 271 | if is_unix then 272 | local md5 = command_exists("md5sum") or command_exists("md5") or command_exists("openssl", "md5 | cut -d ' ' -f 2") 273 | if md5 == nil then 274 | msg.warn("no md5 command found, can't generate hash") 275 | return 276 | end 277 | md5 = table.concat(md5, " ") 278 | cmd["stdin_data"] = path 279 | args = {"sh", "-c", md5 .. " | cut -d ' ' -f 1 | tr '[:lower:]' '[:upper:]'" } 280 | else --windows 281 | -- https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/get-filehash?view=powershell-7.3 282 | local hash_command = [[ 283 | $s = [System.IO.MemoryStream]::new(); 284 | $w = [System.IO.StreamWriter]::new($s); 285 | $w.write(']] .. path .. [['); 286 | $w.Flush(); 287 | $s.Position = 0; 288 | Get-FileHash -Algorithm MD5 -InputStream $s | Select-Object -ExpandProperty Hash 289 | ]] 290 | 291 | args = {"powershell", "-NoProfile", "-Command", hash_command} 292 | end 293 | cmd["args"] = args 294 | msg.debug("hash cmd:", utils.to_string(cmd)) 295 | local process = mp.command_native(cmd) 296 | 297 | if process.status == 0 then 298 | local hash = process.stdout:gsub("%s+", "") 299 | msg.debug("hash:", hash) 300 | return hash 301 | else 302 | msg.warn("hash function failed") 303 | return 304 | end 305 | end 306 | 307 | local function get_chapter_filename(path) 308 | name = hash(path) 309 | if name == nil then 310 | msg.warn("hash function failed, fallback to filename") 311 | name = fname 312 | end 313 | return name 314 | end 315 | 316 | local function mark_chapter(force_overwrite) 317 | refresh_globals() 318 | if not path then return end 319 | 320 | local chapter_index = 0 321 | local chapters_time = {} 322 | local chapters_title = {} 323 | local fpath = dir 324 | if protocol then 325 | fpath = global_chapters_dir 326 | if o.hash then fname = get_chapter_filename(path) end 327 | elseif o.external_chapter_subpath ~= '' then 328 | fpath = utils.join_path(dir, o.external_chapter_subpath) 329 | local meta = utils.file_info(fpath) 330 | if not meta or not meta.is_dir then 331 | fpath = dir 332 | end 333 | end 334 | 335 | if o.global_chapters and global_chapters_dir and global_chapters_dir ~= '' and not protocol then 336 | fpath = global_chapters_dir 337 | local meta = utils.file_info(fpath) 338 | if meta and meta.is_dir then 339 | if o.hash then 340 | fname = get_chapter_filename(path) 341 | end 342 | end 343 | end 344 | 345 | local chapter_filename = fname .. o.chapter_file_ext 346 | chapter_fullpath = utils.join_path(fpath, chapter_filename) 347 | local fmeta = utils.file_info(chapter_fullpath) 348 | if (not fmeta or not fmeta.is_file) and fpath ~= dir and not protocol then 349 | if o.basename_with_ext then 350 | fname = mp.get_property("filename") 351 | else 352 | fname = mp.get_property("filename/no-ext") 353 | end 354 | chapter_filename = fname .. o.chapter_file_ext 355 | chapter_fullpath = utils.join_path(dir, chapter_filename) 356 | end 357 | local list_contents = read_chapter_table() 358 | 359 | if not list_contents then return end 360 | for i = 1, #list_contents do 361 | local chapter_time = tonumber(list_contents[i].found_time) 362 | if chapter_time ~= nil and chapter_time >= 0 then 363 | table.insert(chapters_time, chapter_time) 364 | end 365 | if list_contents[i].found_title ~= nil then 366 | table.insert(chapters_title, list_contents[i].found_title) 367 | end 368 | end 369 | if not chapters_time[1] then return end 370 | 371 | table.sort(chapters_time, function(a, b) return a < b end) 372 | 373 | if force_overwrite then all_chapters = {} end 374 | for i = 1, #chapters_time do 375 | chapter_index = chapter_index + 1 376 | all_chapters[chapter_index] = { 377 | title = chapters_title[i] or ("Chapter " .. string.format("%02.f", chapter_index)), 378 | time = chapters_time[i] 379 | } 380 | end 381 | 382 | table.sort(all_chapters, function(a, b) return a['time'] < b['time'] end) 383 | 384 | mp.set_property_native("chapter-list", all_chapters) 385 | msg.info("load external chapter file successful: " .. chapter_filename) 386 | end 387 | 388 | local function change_chapter_list(chapter_tltle, chapter_index) 389 | local chapter_list = mp.get_property_native("chapter-list") 390 | 391 | if chapter_index > mp.get_property_number("chapter-list/count") then 392 | msg.warn("can't set chapter title") 393 | return 394 | end 395 | 396 | chapter_list[chapter_index].title = chapter_tltle 397 | mp.set_property_native("chapter-list", chapter_list) 398 | end 399 | 400 | local function change_title_callback(user_input, err, chapter_index) 401 | if user_input == nil or err ~= nil then 402 | if paused then return elseif o.pause_on_input then mp.set_property_native("pause", false) end 403 | msg.warn("no chapter title provided:", err) 404 | return 405 | end 406 | change_chapter_list(user_input, chapter_index) 407 | if paused then return elseif o.pause_on_input then mp.set_property_native("pause", false) end 408 | chapters_modified = true 409 | end 410 | 411 | local function input_title(default_input, cursor_pos, chapter_index) 412 | input.get({ 413 | prompt = 'Chapter title:', 414 | default_text = default_input, 415 | cursor_position = cursor_pos, 416 | submit = function(text) 417 | input.terminate() 418 | change_chapter_list(text, chapter_index) 419 | end, 420 | closed = function() 421 | if paused then return elseif o.pause_on_input then mp.set_property_native("pause", false) end 422 | end 423 | }) 424 | end 425 | 426 | local function input_choice(title, chapter_index) 427 | if not input_loaded and not user_input_loaded then 428 | msg.error("no mpv-user-input, can't get user input, install: https://github.com/CogentRedTester/mpv-user-input") 429 | return 430 | end 431 | 432 | if input_loaded then 433 | input_title(title, #title + 1, chapter_index) 434 | elseif user_input_loaded then 435 | -- ask user for chapter title 436 | -- (+1 because mpv indexes from 0, lua from 1) 437 | user_input.get_user_input(change_title_callback, { 438 | request_text = "Chapter title:", 439 | default_input = title, 440 | cursor_pos = #title + 1, 441 | }, chapter_index) 442 | end 443 | end 444 | 445 | local function create_chapter() 446 | refresh_globals() 447 | if not path then return end 448 | 449 | local time_pos = mp.get_property_number("time-pos") 450 | local time_pos_osd = mp.get_property_osd("time-pos/full") 451 | local current_chapter = mp.get_property_number("chapter") 452 | mp.osd_message(time_pos_osd, 1) 453 | 454 | if chapter_count == 0 then 455 | all_chapters[1] = { 456 | title = o.placeholder_title .. "01", 457 | time = time_pos 458 | } 459 | -- We just set it to zero here so when we add 1 later it ends up as 1 460 | -- otherwise it's probably "nil" 461 | current_chapter = 0 462 | -- note that mpv will treat the beginning of the file as all_chapters[0] when using pageup/pagedown 463 | -- so we don't actually have to worry if the file doesn't start with a chapter 464 | else 465 | -- to insert a chapter we have to increase the index on all subsequent chapters 466 | -- otherwise we'll end up with duplicate chapter IDs which will confuse mpv 467 | -- +2 looks weird, but remember mpv indexes at 0 and lua indexes at 1 468 | -- adding two will turn "current chapter" from mpv notation into "next chapter" from lua's notation 469 | -- count down because these areas of memory overlap 470 | for i = chapter_count, current_chapter + 2, -1 do 471 | all_chapters[i + 1] = all_chapters[i] 472 | end 473 | all_chapters[current_chapter + 2] = { 474 | title = o.placeholder_title .. string.format("%02.f", current_chapter + 2), 475 | time = time_pos 476 | } 477 | end 478 | mp.set_property_native("chapter-list", all_chapters) 479 | mp.set_property_number("chapter", current_chapter + 1) 480 | chapters_modified = true 481 | 482 | if o.ask_for_title then 483 | local chapter_index = mp.get_property_number("chapter") + 1 484 | local title = o.placeholder_title .. string.format("%02.f", chapter_index) 485 | 486 | input_choice(title, chapter_index) 487 | 488 | if o.pause_on_input then 489 | paused = mp.get_property_native("pause") 490 | mp.set_property_bool("pause", true) 491 | -- FIXME: for whatever reason osd gets hidden when we pause the 492 | -- playback like that, workaround to make input prompt appear 493 | -- right away without requiring mouse or keyboard action 494 | mp.osd_message(" ", 0.1) 495 | end 496 | end 497 | end 498 | 499 | local function edit_chapter() 500 | local chapter_index = mp.get_property_number("chapter") + 1 501 | local chapter_list = mp.get_property_native("chapter-list") 502 | local title = chapter_list[chapter_index + 1].title 503 | if chapter_index == nil or chapter_index == -1 then 504 | msg.verbose("no chapter selected, nothing to edit") 505 | return 506 | end 507 | 508 | input_choice(title, chapter_index) 509 | 510 | if o.pause_on_input then 511 | paused = mp.get_property_native("pause") 512 | mp.set_property_bool("pause", true) 513 | -- FIXME: for whatever reason osd gets hidden when we pause the 514 | -- playback like that, workaround to make input prompt appear 515 | -- right away without requiring mouse or keyboard action 516 | mp.osd_message(" ", 0.1) 517 | end 518 | end 519 | 520 | local function remove_chapter() 521 | local chapter_count = mp.get_property_number("chapter-list/count") 522 | 523 | if chapter_count < 1 then 524 | msg.verbose("no chapters to remove") 525 | return 526 | end 527 | 528 | local chapter_list = mp.get_property_native("chapter-list") 529 | -- +1 because mpv indexes from 0, lua from 1 530 | local current_chapter = mp.get_property_number("chapter") + 1 531 | 532 | table.remove(chapter_list, current_chapter) 533 | msg.debug("removing chapter", current_chapter) 534 | 535 | mp.set_property_native("chapter-list", chapter_list) 536 | chapters_modified = true 537 | end 538 | 539 | local function write_chapter(format, force_write) 540 | refresh_globals() 541 | if not path or chapter_count == 0 or (not chapters_modified and not force_write) then 542 | msg.debug("nothing to write") 543 | return 544 | end 545 | 546 | if o.global_chapters then dir = global_chapters_dir end 547 | if o.hash and o.global_chapters then fname = get_chapter_filename(path) end 548 | local out_path = utils.join_path(dir, fname .. o.chapter_file_ext) 549 | local chapters = "" 550 | local next_chapter = nil 551 | for i = 1, chapter_count, 1 do 552 | local current_chapter = all_chapters[i] 553 | local time_pos = format_time(current_chapter.time) 554 | if format == "ogm" then 555 | next_chapter = "CHAPTER" .. string.format("%02.f", i) .. "=" .. time_pos .. "\n" .. 556 | "CHAPTER" .. string.format("%02.f", i) .. "NAME=" .. current_chapter.title .. "\n" 557 | elseif format == "chp" then 558 | next_chapter = time_pos .. " " .. current_chapter.title .. "\n" 559 | else 560 | msg.warn("please specify the correct chapter format: chp/ogm.") 561 | return 562 | end 563 | if i == 1 and (o.global_chapters or protocol) then 564 | chapters = "# " .. path .. "\n\n" .. next_chapter 565 | else 566 | chapters = chapters .. next_chapter 567 | end 568 | end 569 | 570 | local file = io.open(out_path, "w") 571 | if file == nil then 572 | dir = global_chapters_dir 573 | fname = url_decode(mp.get_property("media-title")) 574 | if o.hash then fname = get_chapter_filename(path) end 575 | out_path = utils.join_path(dir, fname .. o.chapter_file_ext) 576 | file = io.open(out_path, "w") 577 | end 578 | if file == nil then 579 | mp.error("Could not open chapter file for writing.") 580 | return 581 | end 582 | file:write(chapters) 583 | file:close() 584 | if not o.autosave then 585 | mp.osd_message("Export chapter file to: " .. out_path, 3) 586 | end 587 | msg.info("Export chapter file to: " .. out_path) 588 | end 589 | 590 | -- HOOKS ----------------------------------------------------------------------- 591 | 592 | if o.autoload then 593 | mp.add_hook("on_preloaded", 50, function() 594 | if o.force_overwrite then 595 | mark_chapter(true) 596 | else 597 | mark_chapter(false) 598 | end 599 | end) 600 | end 601 | 602 | if o.autosave then 603 | mp.add_hook("on_unload", 50, function() 604 | write_chapter("chp", false) 605 | end) 606 | end 607 | 608 | if user_input_loaded and not input_loaded then 609 | mp.add_hook("on_unload", 50, function() user_input.cancel_user_input() end) 610 | end 611 | 612 | mp.register_script_message("load_chapter", function() mark_chapter(true) end) 613 | mp.register_script_message("create_chapter", create_chapter, { repeatable = true }) 614 | mp.register_script_message("remove_chapter", remove_chapter) 615 | mp.register_script_message("edit_chapter", edit_chapter) 616 | mp.register_script_message("write_chapter", function(format) 617 | write_chapter(format, true) 618 | end) 619 | -------------------------------------------------------------------------------- /drcbox.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | mpv dynaudnorm filter with visual feedback. 3 | 4 | Copyright 2016 Avi Halachmi ( https://github.com/avih ) 5 | Copyright 2020 Paul B Mahol ( https://github.com/richardpl ) 6 | Copyright 2022 dyphire ( https://github.com/dyphire ) 7 | License: public domain 8 | 9 | -- Source https://gist.github.com/richardpl/0c8011dc23d7ac7b7831b2e6d680114f 10 | 11 | Needs mpv with very recent FFmpeg build. 12 | 13 | Default config: 14 | - Enter/exit drcbox keys mode: alt+n 15 | - Toggle dynaudnorm without changing its values: alt+N 16 | - Reset dynaudnorm values: alt+ctrl+n 17 | 18 | --]] 19 | -- ------ config ------- 20 | 21 | local options = { 22 | language = 'eng', -- eng=English, chs=Chinese 23 | start_keys_enabled = false, -- if true then choose the up/down keys wisely 24 | key_toggle_bindings = "ALT+n", -- enter/exit drcbox keys mode 25 | key_toggle_drcbox = "ALT+N", -- toggle dynaudnorm without changing its values 26 | key_reset_drcbox = "ALT+CTRL+n", -- reset dynaudnorm values 27 | 28 | {keys = {'2', 'w'}, option = {'framelen', 1, 10, 8000, 500, 500 } }, 29 | {keys = {'3', 'e'}, option = {'gausssize', 1, 3, 301, 31, 31 } }, 30 | {keys = {'4', 'r'}, option = {'peak', 0.01, 0, 1, 0.95, 0.95 } }, 31 | {keys = {'5', 't'}, option = {'maxgain', 1, 1, 100, 10, 10 } }, 32 | {keys = {'6', 'y'}, option = {'targetrms', 0.01, 0, 1, 0, 0 } }, 33 | {keys = {'7', 'u'}, option = {'coupling', 1, 0, 1, 1, 1 } }, 34 | {keys = {'8', 'i'}, option = {'correctdc', 1, 0, 1, 0, 0 } }, 35 | {keys = {'9', 'o'}, option = {'compress', 0.1, 0, 30, 0, 0 } }, 36 | } 37 | 38 | (require 'mp.options').read_options(options) 39 | 40 | -- Localization 41 | local language = { 42 | ['eng'] = { 43 | msg1 = 'DynAudNorm: ', 44 | msg2 = 'Key-bindings: ', 45 | msg3 = 'Reset: ', 46 | }, 47 | ['chs'] = { 48 | msg1 = '开/关 dynaudnorm音频处理: ', 49 | msg2 = '开/关 内置键位绑定: ', 50 | msg3 = '重置 dynaudnorm音频处理: ', 51 | } 52 | } 53 | 54 | -- apply lang opts 55 | local texts = language[options.language] 56 | 57 | local function get_cmd_full() 58 | f = options[1].option[5] 59 | g = options[2].option[5] 60 | p = options[3].option[5] 61 | m = options[4].option[5] 62 | r = options[5].option[5] 63 | n = options[6].option[5] 64 | c = options[7].option[5] 65 | s = options[8].option[5] 66 | return 'no-osd af toggle @dynaudnorm:lavfi=[dynaudnorm=f=' .. 67 | f .. ':g=' .. g .. ':p=' .. p .. ':m=' .. m .. ':r=' .. r .. ':n=' .. n .. ':c=' .. c .. ':s=' .. s .. ']' 68 | end 69 | 70 | local function get_cmd(option) 71 | return 'no-osd af-command dynaudnorm ' .. option[1] .. ' ' .. option[5] 72 | end 73 | 74 | -- these two vars are used globally 75 | local bindings_enabled = start_keys_enabled 76 | local drcbox_enabled = false -- but af is not touched before the dynaudnorm is modified 77 | 78 | -- ------ OSD handling ------- 79 | local function ass(x) 80 | return x 81 | end 82 | 83 | local function fsize(s) -- 100 is the normal font size 84 | return ass('{\\fscx' .. s .. '\\fscy' .. s .. '}') 85 | end 86 | 87 | local function color(c) -- c is RRGGBB 88 | return ass('{\\1c&H' .. ss(c, 5, 7) .. ss(c, 3, 5) .. ss(c, 1, 3) .. '&}') 89 | end 90 | 91 | function iff(cc, a, b) if cc then return a else return b end end 92 | 93 | function ss(s, from, to) return s:sub(from, to - 1) end 94 | 95 | local function cnorm() return color('ffffff') end -- white 96 | 97 | local function cdis() return color('909090') end -- grey 98 | 99 | local function ceq() return iff(drcbox_enabled, color('ffff90'), cdis()) end -- yellow-ish 100 | 101 | local function ckeys() return iff(bindings_enabled, color('90FF90'), cdis()) end -- green-ish 102 | 103 | local DUR_DEFAULT = 1.5 -- seconds 104 | local osd_timer = nil 105 | -- duration: seconds, or default if missing/nil, or infinite if 0 (or negative) 106 | local function ass_osd(msg, duration) -- empty or missing msg -> just clears the OSD 107 | duration = duration or DUR_DEFAULT 108 | if not msg or msg == '' then 109 | msg = '{}' -- the API ignores empty string, but '{}' works to clean it up 110 | duration = 0 111 | end 112 | mp.set_osd_ass(0, 0, msg) 113 | if osd_timer then 114 | osd_timer:kill() 115 | osd_timer = nil 116 | end 117 | if duration > 0 then 118 | osd_timer = mp.add_timeout(duration, ass_osd) -- ass_osd() clears without a timer 119 | end 120 | end 121 | 122 | function round(num, numDecimalPlaces) 123 | return tonumber(string.format("%." .. (numDecimalPlaces or 0) .. "f", num)) 124 | end 125 | 126 | -- some visual messing about 127 | local function updateOSD() 128 | local msg1 = fsize(70) .. texts.msg1 .. ceq() .. iff(drcbox_enabled, 'On', 'Off') 129 | .. ' [' .. options.key_toggle_drcbox .. ']' .. cnorm() 130 | local msg2 = fsize(70) 131 | .. texts.msg2 .. ckeys() .. iff(bindings_enabled, 'On', 'Off') 132 | .. ' [' .. options.key_toggle_bindings .. ']' .. cnorm() 133 | local msg3 = fsize(70) 134 | .. texts.msg3 135 | .. ' [' .. options.key_reset_drcbox .. ']' 136 | local msg4 = ' ' 137 | 138 | for i = 1, #options do 139 | local option = options[i].option[1] 140 | local value = round(options[i].option[5], 2) 141 | local default = options[i].option[6] 142 | local info = 143 | ceq() .. fsize(50) .. option .. ' ' .. fsize(100) 144 | .. iff(value ~= default and drcbox_enabled, '', cdis()) .. value .. ceq() 145 | .. fsize(50) .. ckeys() .. ' [' .. options[i].keys[1] .. '/' .. options[i].keys[2] .. ']' 146 | .. ceq() .. fsize(100) .. cnorm() 147 | 148 | msg4 = msg4 .. ' ' .. info 149 | end 150 | 151 | local nlb = '\n' .. ass('{\\an1}') -- new line and "align bottom for next" 152 | local msg = ass('{\\an1}') .. msg4 .. nlb .. msg3 .. nlb .. msg2 .. nlb .. msg1 153 | local duration = iff(start_keys_enabled, iff(bindings_enabled and drcbox_enabled, 5, nil) 154 | , iff(bindings_enabled, 0, nil)) 155 | ass_osd(msg, duration) 156 | end 157 | 158 | local function update_key_binding(enable, key, name, fn) 159 | if enable then 160 | mp.add_forced_key_binding(key, name, fn, 'repeatable') 161 | else 162 | mp.remove_key_binding(name) 163 | end 164 | end 165 | 166 | local function updateAF() 167 | mp.command(get_cmd_full()) 168 | end 169 | 170 | local function updateAF_options() 171 | if not drcbox_enabled then return end 172 | for i = 1, #options do 173 | local o = options[i].option 174 | mp.command(get_cmd(o)) 175 | end 176 | end 177 | 178 | local function getBind(option, delta) 179 | return function() -- onKey 180 | option[5] = option[5] + delta 181 | if option[5] > option[4] then 182 | option[5] = option[4] 183 | end 184 | if option[5] < option[3] then 185 | option[5] = option[3] 186 | end 187 | updateAF_options() 188 | updateOSD() 189 | end 190 | end 191 | 192 | function toggle_drcbox() 193 | drcbox_enabled = not drcbox_enabled 194 | updateAF() 195 | updateOSD() 196 | end 197 | 198 | function reset_drcbox() 199 | for i = 1, #options do 200 | options[i].option[5] = options[i].option[6] 201 | end 202 | updateAF_options() 203 | updateOSD() 204 | end 205 | 206 | local function toggle_bindings(explicit, no_osd) 207 | bindings_enabled = iff(explicit ~= nil, explicit, not bindings_enabled) 208 | for i = 1, #options do 209 | local keys = options[i].keys 210 | local option = options[i].option[1] 211 | local delta = options[i].option[2] 212 | update_key_binding(bindings_enabled, options.key_toggle_drcbox, options.key_toggle_drcbox, toggle_drcbox) 213 | update_key_binding(bindings_enabled, options.key_reset_drcbox, options.key_reset_drcbox, reset_drcbox) 214 | update_key_binding(bindings_enabled, keys[1], 'eq' .. keys[1], getBind(options[i].option, delta)) -- up 215 | update_key_binding(bindings_enabled, keys[2], 'eq' .. keys[2], getBind(options[i].option, -delta)) -- down 216 | end 217 | if not no_osd then updateOSD() end 218 | end 219 | 220 | mp.add_key_binding(options.key_toggle_bindings, "key_toggle_bindings", toggle_bindings) 221 | if bindings_enabled then toggle_bindings(true, true) end 222 | -------------------------------------------------------------------------------- /edition-list.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | SOURCE_: https://github.com/CogentRedTester/mpv-scripts 3 | Modify_: https://github.com/dyphire/mpv-scripts 4 | 5 | Shows a notification when the file loads if it has multiple editions 6 | switches the osd-playing-message to show the list of editions to allow for better edition navigation 7 | 8 | This script also implements an interractive track list 9 | Usage: add bindings to input.conf 10 | -- key script-message-to edition_list toggle-edition-browser 11 | 12 | This script needs to be used with scroll-list.lua 13 | https://github.com/CogentRedTester/mpv-scroll-list 14 | ]] -- 15 | 16 | local msg = require 'mp.msg' 17 | local mp = require 'mp' 18 | local opts = require("mp.options") 19 | 20 | local o = { 21 | -- set the delay for displaying OSD information in seconds 22 | timeout = 3, 23 | -- header of the list 24 | -- %cursor% and %total% to be used to display the cursor position and the total number of lists 25 | header = "Edition List [%cursor%/%total%]\\N ------------------------------------", 26 | --list ass style overrides inside curly brackets 27 | --these styles will be used for the whole list. so you need to reset them for every line 28 | --read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags 29 | global_style = [[]], 30 | header_style = [[{\q2\fs30\c&00ccff&}]], 31 | list_style = [[{\q2\fs20\c&Hffffff&}]], 32 | wrapper_style = [[{\c&00ccff&\fs16}]], 33 | cursor_style = [[{\c&00ccff&}]], 34 | selected_style = [[{\c&Hfce788&}]], 35 | active_style = [[{\c&H33ff66&}]], 36 | cursor = [[➤\h]], 37 | indent = [[\h\h\h]], 38 | --amount of entries to show before slicing. Optimal value depends on font/video size etc. 39 | num_entries = 16, 40 | --slice long filenames, and how many chars to show 41 | max_title_length = 100, 42 | -- wrap the cursor around the top and bottom of the list 43 | wrap = true, 44 | -- set dynamic keybinds to bind when the list is open 45 | key_move_begin = "HOME", 46 | key_move_end = "END", 47 | key_move_pageup = "PGUP", 48 | key_move_pagedown = "PGDWN", 49 | key_scroll_down = "DOWN WHEEL_DOWN", 50 | key_scroll_up = "UP WHEEL_UP", 51 | key_select_edition = "ENTER MBTN_LEFT", 52 | key_close_browser = "ESC MBTN_RIGHT", 53 | } 54 | 55 | opts.read_options(o) 56 | 57 | --adding the source directory to the package path and loading the module 58 | package.path = mp.command_native({"expand-path", "~~/script-modules/?.lua;"}) .. package.path 59 | local list = require "scroll-list" 60 | 61 | playingMessage = mp.get_property('options/osd-playing-msg') 62 | editionSwitching = false 63 | lastFilename = "" 64 | 65 | --shows a message on the OSD if the file has editions 66 | function showNotification() 67 | local editions = mp.get_property_number('editions', 0) 68 | 69 | --if there are no editions (or 1 dummy edition) then exit the function 70 | if editions < 2 then return end 71 | 72 | local time = mp.get_time() 73 | while (mp.get_time() - time < 1) do 74 | 75 | end 76 | mp.add_timeout(o.timeout, function() 77 | mp.osd_message('file has ' .. editions .. ' editions', '2') 78 | end) 79 | end 80 | 81 | --The script remembers the first time the edition is switched using mp.observe_property, and afterwards always displays the edition-list on each file-loaded 82 | --event, instead of the default osd-playting-msg. The script needs to compare the filenames each time in order to test when a new file has been loaded. 83 | --When this happens it resets the editionSwitching boolean and displays the original osd-playing-message. 84 | --This process is necessary because there seems to be no way to differentiate between a new file being loaded and a new edition being loaded 85 | function main() 86 | local edition = mp.get_property_number('current-edition') 87 | 88 | --resets editionSwitching boolean and sets the new filename 89 | if lastFilename ~= mp.get_property('filename') then 90 | changedFile() 91 | lastFilename = mp.get_property('filename') 92 | 93 | --if the file is new then it runs then notification function 94 | showNotification() 95 | end 96 | 97 | if (editionSwitching == false or edition == nil) then 98 | mp.set_property('options/osd-playing-msg', playingMessage) 99 | else 100 | mp.set_property('options/osd-playing-msg', '${edition-list}') 101 | end 102 | end 103 | 104 | --logs when the edition is changed 105 | function editionChanged() 106 | msg.log('v', 'edition changed') 107 | editionSwitching = true 108 | end 109 | 110 | --resets the edition switch boolean on a file change 111 | function changedFile() 112 | msg.log('v', 'switched file') 113 | editionSwitching = false 114 | end 115 | 116 | --modifying the list settings 117 | list.header = o.header 118 | list.cursor = o.cursor 119 | list.indent = o.indent 120 | list.wrap = o.wrap 121 | list.num_entries = o.num_entries 122 | list.global_style = o.global_style 123 | list.header_style = o.header_style 124 | list.list_style = o.list_style 125 | list.wrapper_style = o.wrapper_style 126 | list.cursor_style = o.cursor_style 127 | list.selected_style = o.selected_style 128 | 129 | --escape header specifies the format 130 | --display the cursor position and the total number of lists in the header 131 | function list:format_header_string(str) 132 | if #list.list > 0 then 133 | str = str:gsub("%%(%a+)%%", { cursor = list.selected, total = #list.list }) 134 | else str = str:gsub("%[.*%]", "") end 135 | return str 136 | end 137 | 138 | --update the list when the current edition changes 139 | local function edition_list() 140 | mp.observe_property('edition-list', 'native', function(_, edition_list) 141 | mp.observe_property('current-edition', 'number', function(_, curr_edition) 142 | list.list = {} 143 | if edition_list == nil then 144 | list:update() 145 | return 146 | end 147 | for i = 1, #edition_list do 148 | local item = {} 149 | local title = edition_list[i].title 150 | if not title then title = "Edition " .. string.format("%02.f", i) end 151 | if o.max_title_length > 0 and title:len() > o.max_title_length + 5 then 152 | title = title:sub(1, o.max_title_length) .. " ..." 153 | end 154 | if (i - 1 == curr_edition) then 155 | list.selected = curr_edition + 1 156 | item.style = o.active_style 157 | item.ass = "● " .. list.ass_escape(title) 158 | else 159 | item.ass = "○ " .. list.ass_escape(title) 160 | end 161 | list.list[i] = item 162 | end 163 | list:update() 164 | end) 165 | end) 166 | list:toggle() 167 | end 168 | 169 | --jump to the selected edition 170 | local function select_edition() 171 | if list.list[list.selected] then 172 | mp.set_property_number('edition', list.selected - 1) 173 | end 174 | end 175 | 176 | --dynamic keybinds to bind when the list is open 177 | list.keybinds = {} 178 | 179 | local function add_keys(keys, name, fn, flags) 180 | local i = 1 181 | for key in keys:gmatch("%S+") do 182 | table.insert(list.keybinds, { key, name .. i, fn, flags }) 183 | i = i + 1 184 | end 185 | end 186 | 187 | add_keys(o.key_scroll_down, 'scroll_down', function() list:scroll_down() end, { repeatable = true }) 188 | add_keys(o.key_scroll_up, 'scroll_up', function() list:scroll_up() end, { repeatable = true }) 189 | add_keys(o.key_move_pageup, 'move_pageup', function() list:move_pageup() end, {}) 190 | add_keys(o.key_move_pagedown, 'move_pagedown', function() list:move_pagedown() end, {}) 191 | add_keys(o.key_move_begin, 'move_begin', function() list:move_begin() end, {}) 192 | add_keys(o.key_move_end, 'move_end', function() list:move_end() end, {}) 193 | add_keys(o.key_select_edition, 'select_edition', select_edition, {}) 194 | add_keys(o.key_close_browser, 'close_browser', function() list:close() end, {}) 195 | 196 | mp.register_script_message("toggle-edition-browser", edition_list) 197 | 198 | mp.register_event('file-loaded', main) 199 | mp.register_event('end-file', function() 200 | list:close() 201 | mp.unobserve_property(edition_list) 202 | end) -------------------------------------------------------------------------------- /hdr-mode.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2025 dyphire 2 | -- License: MIT 3 | -- link: https://github.com/dyphire/mpv-scripts 4 | -- Automatically switches the display's SDR and HDR modes for HDR passthrough 5 | -- based on the content of the video being played by the mpv, only works on Windows 10 and later systems 6 | 7 | --! Required for use with mpv-display-plugin: https://github.com/dyphire/mpv-display-plugin 8 | 9 | local msg = require 'mp.msg' 10 | local utils = require 'mp.utils' 11 | local options = require 'mp.options' 12 | 13 | local o = { 14 | -- Specify the script working mode, value: noth, pass, switch. default: noth 15 | -- noth: Do nothing 16 | -- pass: Passing HDR signals for HDR content when the monitor is in HDR mode 17 | -- switch: Automatically switch between HDR displays and SDR displays 18 | -- on Windows 10 and later based on video specifications 19 | hdr_mode = "noth", 20 | -- Specify whether to switch HDR mode only when the window is in fullscreen or window maximized 21 | -- only works with hdr_mode = "switch", default: false 22 | fullscreen_only = false, 23 | -- Specify the target peak of the HDR display, default: 203 24 | -- must be the true peak brightness of the monitor, 25 | -- otherwise it will cause HDR content to display incorrectly 26 | target_peak = "203", 27 | -- Specifies the measured contrast of the output display. 28 | -- Used in black point compensation during HDR tone-mapping and HDR passthrough. 29 | -- Must be the true contrast information of the display, e.g. 100000 means 100000:1 maximum contrast 30 | -- OLED display do not need to change this, default: auto 31 | target_contrast = "auto", 32 | } 33 | options.read_options(o, _, function() end) 34 | 35 | local hdr_active = false 36 | local hdr_supported = false 37 | local first_switch_check = true 38 | 39 | local state = { 40 | target_peak = mp.get_property_native("target-peak"), 41 | target_prim = mp.get_property_native("target-prim"), 42 | target_trc = mp.get_property_native("target-trc"), 43 | target_contrast = mp.get_property_native("target_contrast"), 44 | colorspace_hint = mp.get_property_native("target-colorspace-hint"), 45 | inverse_mapping = mp.get_property_native("inverse-tone-mapping") 46 | } 47 | 48 | local function query_hdr_state() 49 | hdr_supported = mp.get_property_native("user-data/display-info/hdr-supported") 50 | hdr_active = mp.get_property_native("user-data/display-info/hdr-status") == "on" 51 | end 52 | 53 | local function switch_display_mode(enable) 54 | if enable == hdr_active then return end 55 | local arg = enable and "on" or "off" 56 | mp.commandv('script-message', 'toggle-hdr-display', arg) 57 | end 58 | 59 | local function apply_hdr_settings() 60 | mp.set_property_native("target-prim", "bt.2020") 61 | mp.set_property_native("target-trc", "pq") 62 | mp.set_property_native("target-peak", o.target_peak) 63 | mp.set_property_native("target-contrast", o.target_contrast) 64 | mp.set_property_native("target-colorspace-hint", "yes") 65 | mp.set_property_native("inverse-tone-mapping", "no") 66 | end 67 | 68 | local function apply_sdr_settings() 69 | mp.set_property_native("target-peak", "203") 70 | mp.set_property_native("target-contrast", state.target_contrast) 71 | mp.set_property_native("target-colorspace-hint", "no") 72 | if state.target_prim ~= "bt.2020" then 73 | mp.set_property_native("target-prim", state.target_prim) 74 | else 75 | mp.set_property_native("target-prim", "auto") 76 | end 77 | if state.target_trc ~= "pq" then 78 | mp.set_property_native("target-trc", state.target_trc) 79 | else 80 | mp.set_property_native("target-trc", "auto") 81 | end 82 | end 83 | 84 | local function reset_target_settings() 85 | mp.set_property_native("target-peak", state.target_peak) 86 | mp.set_property_native("target-prim", state.target_prim) 87 | mp.set_property_native("target-trc", state.target_trc) 88 | mp.set_property_native("target-contrast", state.target_contrast) 89 | mp.set_property_native("target-colorspace-hint", state.colorspace_hint) 90 | mp.set_property_native("inverse-tone-mapping", state.inverse_mapping) 91 | end 92 | 93 | local function pause_if_needed() 94 | local paused = mp.get_property_native("pause") 95 | if not paused then 96 | mp.set_property_native("pause", true) 97 | return true 98 | end 99 | return false 100 | end 101 | 102 | local function resume_if_needed(paused_before) 103 | if paused_before then 104 | mp.add_timeout(1, function() 105 | mp.set_property_native("pause", false) 106 | end) 107 | end 108 | end 109 | 110 | local function handle_hdr_logic(paused_before, target_peak) 111 | query_hdr_state() 112 | if hdr_active and o.hdr_mode ~= "noth" then 113 | apply_hdr_settings() 114 | resume_if_needed(paused_before) 115 | elseif not hdr_active and o.hdr_mode ~= "noth" and tonumber(target_peak) ~= 203 then 116 | apply_sdr_settings() 117 | end 118 | end 119 | 120 | local function handle_sdr_logic(paused_before, target_peak) 121 | query_hdr_state() 122 | if not hdr_active or o.hdr_mode ~= "noth" then 123 | if tonumber(target_peak) ~= 203 and (not hdr_active or not state.inverse_mapping) then 124 | apply_sdr_settings() 125 | elseif hdr_active and state.inverse_mapping then 126 | reset_target_settings() 127 | end 128 | resume_if_needed(paused_before) 129 | end 130 | if hdr_active and o.hdr_mode == "pass" and state.inverse_mapping then 131 | reset_target_settings() 132 | end 133 | end 134 | 135 | local function should_switch_hdr(hdr_active, is_fullscreen) 136 | if o.hdr_mode ~= "switch" then return false end 137 | if not hdr_active and (not o.fullscreen_only or is_fullscreen) then 138 | return true 139 | elseif hdr_active and o.fullscreen_only and not is_fullscreen then 140 | return true 141 | end 142 | return false 143 | end 144 | 145 | local function switch_hdr() 146 | query_hdr_state() 147 | local params = mp.get_property_native("video-params") 148 | local gamma = params and params["gamma"] 149 | local max_luma = params and params["max-luma"] 150 | local is_hdr = max_luma and max_luma > 203 151 | if not gamma then return end 152 | 153 | local current_state = is_hdr and "hdr" or "sdr" 154 | local pause_changed = false 155 | local fullscreen = mp.get_property_native("fullscreen") 156 | local maximized = mp.get_property_native("window-maximized") 157 | local target_peak = mp.get_property_native("target-peak") 158 | local is_fullscreen = fullscreen or maximized 159 | 160 | if current_state == "hdr" then 161 | local function continue_hdr() 162 | handle_hdr_logic(pause_changed, target_peak) 163 | end 164 | 165 | if first_switch_check and o.fullscreen_only and not is_fullscreen then 166 | first_switch_check = false 167 | elseif should_switch_hdr(hdr_active, is_fullscreen) then 168 | pause_changed = pause_if_needed() 169 | if hdr_active and o.fullscreen_only and not is_fullscreen then 170 | msg.info("Switching to SDR output...") 171 | switch_display_mode(false) 172 | else 173 | msg.info("Switching to HDR output...") 174 | switch_display_mode(true) 175 | end 176 | mp.add_timeout(3, continue_hdr) 177 | return 178 | end 179 | 180 | handle_hdr_logic(false, target_peak) 181 | 182 | elseif current_state == "sdr" then 183 | local function continue_sdr() 184 | handle_sdr_logic(pause_changed, target_peak) 185 | end 186 | 187 | if hdr_active and o.hdr_mode == "switch" and (not o.fullscreen_only or is_fullscreen) then 188 | msg.info("Switching back to SDR output...") 189 | pause_changed = pause_if_needed() 190 | switch_display_mode(false) 191 | mp.add_timeout(3, continue_sdr) 192 | return 193 | end 194 | 195 | handle_sdr_logic(false, target_peak) 196 | end 197 | end 198 | 199 | local function check_paramet() 200 | query_hdr_state() 201 | local target_peak = mp.get_property_native("target-peak") 202 | local target_prim = mp.get_property_native("target-prim") 203 | local target_trc = mp.get_property_native("target-trc") 204 | local target_contrast = mp.get_property_native("target-contrast") 205 | local colorspace_hint = mp.get_property_native("target-colorspace-hint") 206 | local inverse_mapping = mp.get_property_native("inverse-tone-mapping") 207 | local params = mp.get_property_native("video-params") 208 | local gamma = params and params["gamma"] 209 | local max_luma = params and params["max-luma"] 210 | local is_hdr = max_luma and max_luma > 203 211 | if not gamma then return end 212 | 213 | if is_hdr and hdr_active and o.hdr_mode ~= "noth" then 214 | if target_peak ~= o.target_peak then 215 | mp.set_property_native("target-peak", o.target_peak) 216 | end 217 | if target_contrast ~= o.target_contrast then 218 | mp.set_property_native("target-contrast", o.target_contrast) 219 | end 220 | if target_prim ~= "bt.2020" then 221 | mp.set_property_native("target-prim", "bt.2020") 222 | end 223 | if target_trc ~= "pq" then 224 | mp.set_property_native("target-trc", "pq") 225 | end 226 | if colorspace_hint ~= "yes" then 227 | mp.set_property_native("target-colorspace-hint", "yes") 228 | end 229 | if inverse_mapping then 230 | mp.set_property_native("inverse-tone-mapping", "no") 231 | end 232 | end 233 | if not is_hdr and o.hdr_mode ~= "noth" and not state.inverse_mapping 234 | and (tonumber(target_peak) ~= 203 or target_prim == "bt.2020" or target_trc == "pq") then 235 | apply_sdr_settings() 236 | end 237 | end 238 | 239 | local function on_start() 240 | if o.hdr_mode == "noth" or tonumber(o.target_peak) <= 203 then 241 | return 242 | end 243 | local vo = mp.get_property("vo") 244 | if vo and vo ~= "gpu-next" then 245 | msg.warn("The current video output is not supported, please use gpu-next") 246 | return 247 | end 248 | query_hdr_state() 249 | mp.observe_property("video-params", "native", switch_hdr) 250 | mp.observe_property("target-peak", "native", check_paramet) 251 | mp.observe_property("target-prim", "native", check_paramet) 252 | mp.observe_property("target-trc", "native", check_paramet) 253 | mp.observe_property("target-contrast", "native", check_paramet) 254 | mp.observe_property("target-colorspace-hint", "native", check_paramet) 255 | mp.observe_property("user-data/display-info/hdr-status", "native", switch_hdr) 256 | if o.fullscreen_only then 257 | mp.observe_property("fullscreen", "native", switch_hdr) 258 | mp.observe_property("window-maximized", "native", switch_hdr) 259 | end 260 | end 261 | 262 | local function on_end(event) 263 | query_hdr_state() 264 | first_switch_check = true 265 | mp.unobserve_property(switch_hdr) 266 | mp.unobserve_property(check_paramet) 267 | if event["reason"] == "quit" then 268 | if hdr_active and o.hdr_mode == "switch" then 269 | msg.info("Restoring display to SDR on shutdown") 270 | switch_display_mode(false) 271 | end 272 | end 273 | end 274 | 275 | local function on_idle(_, active) 276 | local target_peak = mp.get_property_native("target-peak") 277 | if active and o.hdr_mode ~= "noth" and tonumber(target_peak) ~= 203 then 278 | apply_sdr_settings() 279 | end 280 | end 281 | 282 | mp.register_event("start-file", on_start) 283 | mp.register_event("end-file", on_end) 284 | mp.observe_property("idle-active", "native", on_idle) 285 | -------------------------------------------------------------------------------- /history-bookmark.lua: -------------------------------------------------------------------------------- 1 | --lite version of the code written by sorayuki 2 | --only keep the function to record the histroy and recover it 3 | 4 | local mp = require 'mp' 5 | local utils = require 'mp.utils' 6 | local options = require 'mp.options' 7 | local msg = require 'mp.msg' -- this is for debugging 8 | 9 | local o = { 10 | enabled = true, 11 | -- eng=English, chs=Chinese Simplified 12 | language = 'eng', 13 | timeout = 15, 14 | save_period = 30, 15 | -- Set '/:dir%mpvconf%/historybookmarks' to use mpv config directory 16 | -- OR change to '/:dir%script%/historybookmarks' for placing it in the same directory of script 17 | -- OR change to '~~/historybookmarks' for sub path of mpv portable_config directory 18 | -- OR write any variable using '/:var', such as: '/:var%APPDATA%/mpv/historybookmarks' or '/:var%HOME%/mpv/historybookmarks' 19 | -- OR specify the absolute path 20 | history_dir = "/:dir%mpvconf%/historybookmarks", 21 | -- specifies the extension of the history-bookmark file 22 | bookmark_ext = ".mpv.history", 23 | -- use hash to bookmark_name 24 | hash = true, 25 | -- set false to get playlist from directory 26 | use_playlist = true, 27 | -- specifies a whitelist of files to find in a directory 28 | whitelist = "3gp,amr,amv,asf,avi,avi,bdmv,f4v,flv,m2ts,m4v,mkv,mov,mp4,mpeg,mpg,ogv,rm,rmvb,ts,vob,webm,wmv", 29 | -- excluded directories for shared, #windows: ["X:", "Z:", "F:/Download/", "Download"] 30 | excluded_dir = [[ 31 | [] 32 | ]], 33 | included_dir = [[ 34 | [] 35 | ]] 36 | } 37 | options.read_options(o, _, function() end) 38 | 39 | o.excluded_dir = utils.parse_json(o.excluded_dir) 40 | o.included_dir = utils.parse_json(o.included_dir) 41 | 42 | local file_loaded = false 43 | 44 | local locals = { 45 | ['eng'] = { 46 | msg1 = 'Resume successfully', 47 | msg2 = 'Resume the last played file in current directory', 48 | msg3 = 'Press 1 to confirm, 0 to cancel', 49 | }, 50 | ['chs'] = { 51 | msg1 = '成功恢复上次播放', 52 | msg2 = '是否恢复当前目录的上次播放文件', 53 | msg3 = '按1确认,按0取消', 54 | } 55 | } 56 | 57 | -- apply lang opts 58 | local texts = locals[o.language] 59 | 60 | -- `pl` stands for playlist 61 | local path = nil 62 | local dir = nil 63 | local fname = nil 64 | local pl_count = 0 65 | local pl_name = nil 66 | local pl_path = nil 67 | local pl_list = {} 68 | local pl_idx = 1 69 | local current_idx = 1 70 | local bookmark_path = nil 71 | local history_dir = nil 72 | local normalize_path = nil 73 | 74 | local wait_msg 75 | local on_key = false 76 | 77 | if o.history_dir:find('^/:dir%%mpvconf%%') then 78 | history_dir = o.history_dir:gsub('/:dir%%mpvconf%%', mp.find_config_file('.')) 79 | elseif o.history_dir:find('^/:dir%%script%%') then 80 | history_dir = o.history_dir:gsub('/:dir%%script%%', mp.find_config_file('scripts')) 81 | elseif o.history_dir:find('/:var%%(.*)%%') then 82 | local os_variable = o.history_dir:match('/:var%%(.*)%%') 83 | history_dir = o.history_dir:gsub('/:var%%(.*)%%', os.getenv(os_variable)) 84 | else 85 | history_dir = mp.command_native({ "expand-path", o.history_dir }) -- Expands both ~ and ~~ 86 | end 87 | 88 | local is_windows = package.config:sub(1, 1) == "\\" -- detect path separator, detect path separator, windows uses backslashes 89 | --create history_dir if it doesn't exist 90 | if history_dir ~= '' then 91 | local meta = utils.file_info(history_dir) 92 | if not meta or not meta.is_dir then 93 | local windows_args = { 'powershell', '-NoProfile', '-Command', 'mkdir', string.format("\"%s\"", history_dir) } 94 | local unix_args = { 'mkdir', '-p', history_dir } 95 | local args = is_windows and windows_args or unix_args 96 | local res = mp.command_native({ name = "subprocess", capture_stdout = true, playback_only = false, args = args }) 97 | if res.status ~= 0 then 98 | msg.error("Failed to create history_dir save directory " .. history_dir .. 99 | ". Error: " .. (res.error or "unknown")) 100 | return 101 | end 102 | end 103 | end 104 | 105 | local function split(input) 106 | local ret = {} 107 | for str in string.gmatch(input, "([^,]+)") do 108 | ret[#ret + 1] = str 109 | end 110 | return ret 111 | end 112 | 113 | local ext_whitelist = split(o.whitelist) 114 | 115 | local function exclude(extension) 116 | if #ext_whitelist > 0 then 117 | for _, ext in pairs(ext_whitelist) do 118 | if extension == ext then 119 | return true 120 | end 121 | end 122 | else 123 | return 124 | end 125 | end 126 | 127 | local function is_protocol(path) 128 | return type(path) == 'string' and (path:find('^%a[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= nil) 129 | end 130 | 131 | local function need_ignore(tab, val) 132 | for _, element in pairs(tab) do 133 | if string.find(val, element) then 134 | return true 135 | end 136 | end 137 | return false 138 | end 139 | 140 | local function tablelength(tab) 141 | local count = 0 142 | for _, _ in pairs(tab) do 143 | count = count + 1 144 | end 145 | return count 146 | end 147 | 148 | local message_overlay = mp.create_osd_overlay('ass-events') 149 | local message_timer = mp.add_timeout(1, function () 150 | message_overlay:remove() 151 | end, true) 152 | 153 | function show_message(text, time) 154 | message_timer:kill() 155 | message_timer.timeout = time or 1 156 | message_overlay.data = text 157 | message_overlay:update() 158 | message_timer:resume() 159 | end 160 | 161 | local function normalize(path) 162 | if normalize_path ~= nil then 163 | if normalize_path then 164 | path = mp.command_native({"normalize-path", path}) 165 | else 166 | local directory = mp.get_property("working-directory", "") 167 | path = utils.join_path(directory, path:gsub('^%.[\\/]','')) 168 | if is_windows then path = path:gsub("\\", "/") end 169 | end 170 | return path 171 | end 172 | 173 | normalize_path = false 174 | 175 | local commands = mp.get_property_native("command-list", {}) 176 | for _, command in ipairs(commands) do 177 | if command.name == "normalize-path" then 178 | normalize_path = true 179 | break 180 | end 181 | end 182 | return normalize(path) 183 | end 184 | 185 | function refresh_globals() 186 | path = mp.get_property("path") 187 | fname = mp.get_property("filename") 188 | pl_count = mp.get_property_number('playlist-count', 0) 189 | if path and not is_protocol(path) then 190 | path = normalize(path) 191 | dir = utils.split_path(path) 192 | else 193 | dir = nil 194 | end 195 | end 196 | 197 | -- for unix use only 198 | -- returns a table of command path and varargs, or nil if command was not found 199 | local function command_exists(command, ...) 200 | msg.debug("looking for command:", command) 201 | -- msg.debug("args:", ) 202 | local process = mp.command_native({ 203 | name = "subprocess", 204 | capture_stdout = true, 205 | capture_stderr = true, 206 | playback_only = false, 207 | args = {"sh", "-c", "command -v -- " .. command} 208 | }) 209 | 210 | if process.status == 0 then 211 | local command_path = process.stdout:gsub("\n", "") 212 | msg.debug("command found:", command_path) 213 | return {command_path, ...} 214 | else 215 | msg.debug("command not found:", command) 216 | return nil 217 | end 218 | end 219 | 220 | -- returns md5 hash of the full path of the current media file 221 | local function hash(path) 222 | if path == nil then 223 | msg.debug("something is wrong with the path, can't get full_path, can't hash it") 224 | return 225 | end 226 | 227 | msg.debug("hashing:", path) 228 | 229 | local cmd = { 230 | name = 'subprocess', 231 | capture_stdout = true, 232 | playback_only = false, 233 | } 234 | 235 | local args = nil 236 | local is_unix = package.config:sub(1,1) == "/" 237 | if is_unix then 238 | local md5 = command_exists("md5sum") or command_exists("md5") or command_exists("openssl", "md5 | cut -d ' ' -f 2") 239 | if md5 == nil then 240 | msg.warn("no md5 command found, can't generate hash") 241 | return 242 | end 243 | md5 = table.concat(md5, " ") 244 | cmd["stdin_data"] = path 245 | args = {"sh", "-c", md5 .. " | cut -d ' ' -f 1 | tr '[:lower:]' '[:upper:]'" } 246 | else --windows 247 | -- https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/get-filehash?view=powershell-7.3 248 | local hash_command = [[ 249 | $s = [System.IO.MemoryStream]::new(); 250 | $w = [System.IO.StreamWriter]::new($s); 251 | $w.write(']] .. path .. [['); 252 | $w.Flush(); 253 | $s.Position = 0; 254 | Get-FileHash -Algorithm MD5 -InputStream $s | Select-Object -ExpandProperty Hash 255 | ]] 256 | 257 | args = {"powershell", "-NoProfile", "-Command", hash_command} 258 | end 259 | cmd["args"] = args 260 | msg.debug("hash cmd:", utils.to_string(cmd)) 261 | local process = mp.command_native(cmd) 262 | 263 | if process.status == 0 then 264 | local hash = process.stdout:gsub("%s+", "") 265 | msg.debug("hash:", hash) 266 | return hash 267 | else 268 | msg.warn("hash function failed") 269 | return 270 | end 271 | end 272 | 273 | local function get_bookmark_path(dir) 274 | local fpath = string.sub(dir, 1, -2) 275 | local _, name = utils.split_path(fpath) 276 | local history_name = nil 277 | if o.hash then 278 | history_name = hash(dir) 279 | if history_name == nil then 280 | msg.warn("hash function failed, fallback to dirname") 281 | history_name = name 282 | end 283 | else 284 | history_name = name 285 | end 286 | local bookmark_name = history_name .. o.bookmark_ext 287 | bookmark_path = utils.join_path(history_dir, bookmark_name) 288 | if is_windows then bookmark_path = bookmark_path:gsub("\\", "/") end 289 | end 290 | 291 | local function file_exist(path) 292 | local meta = utils.file_info(path) 293 | if not meta or not meta.is_file then 294 | return false 295 | end 296 | return true 297 | end 298 | 299 | -- get the content of the bookmark 300 | -- Arg: bookmark_file (path) 301 | -- Return: nil / content of the bookmark 302 | local function get_record(bookmark_path) 303 | local file = io.open(bookmark_path, 'r') 304 | local record = file:read() 305 | if record == nil then 306 | msg.verbose('No history record is found in the bookmark file.') 307 | return nil 308 | end 309 | msg.verbose('last play: ' .. record) 310 | file:close() 311 | return record 312 | end 313 | 314 | ----- winapi start ----- 315 | -- in windows system, we can use the sorting function provided by the win32 API 316 | -- see https://learn.microsoft.com/en-us/windows/win32/api/shlwapi/nf-shlwapi-strcmplogicalw 317 | -- this function was taken from https://github.com/mpvnet-player/mpv.net/issues/575#issuecomment-1817413401 318 | local winapi = {} 319 | local is_windows = mp.get_property_native("platform") == "windows" 320 | 321 | if is_windows then 322 | -- is_ffi_loaded is false usually means the mpv builds without luajit 323 | local is_ffi_loaded, ffi = pcall(require, "ffi") 324 | 325 | if is_ffi_loaded then 326 | winapi = { 327 | ffi = ffi, 328 | C = ffi.C, 329 | CP_UTF8 = 65001, 330 | shlwapi = ffi.load("shlwapi"), 331 | } 332 | 333 | -- ffi code from https://github.com/po5/thumbfast, Mozilla Public License Version 2.0 334 | ffi.cdef[[ 335 | int __stdcall MultiByteToWideChar(unsigned int CodePage, unsigned long dwFlags, const char *lpMultiByteStr, 336 | int cbMultiByte, wchar_t *lpWideCharStr, int cchWideChar); 337 | int __stdcall StrCmpLogicalW(wchar_t *psz1, wchar_t *psz2); 338 | ]] 339 | 340 | winapi.utf8_to_wide = function(utf8_str) 341 | if utf8_str then 342 | local utf16_len = winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, utf8_str, -1, nil, 0) 343 | 344 | if utf16_len > 0 then 345 | local utf16_str = winapi.ffi.new("wchar_t[?]", utf16_len) 346 | 347 | if winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, utf8_str, -1, utf16_str, utf16_len) > 0 then 348 | return utf16_str 349 | end 350 | end 351 | end 352 | 353 | return "" 354 | end 355 | end 356 | end 357 | ----- winapi end ----- 358 | 359 | local function alphanumsort_windows(filenames) 360 | table.sort(filenames, function(a, b) 361 | local a_wide = winapi.utf8_to_wide(a) 362 | local b_wide = winapi.utf8_to_wide(b) 363 | return winapi.shlwapi.StrCmpLogicalW(a_wide, b_wide) == -1 364 | end) 365 | 366 | return filenames 367 | end 368 | 369 | -- alphanum sorting for humans in Lua 370 | -- http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua 371 | local function alphanumsort_lua(filenames) 372 | local function padnum(n, d) 373 | return #d > 0 and ("%03d%s%.12f"):format(#n, n, tonumber(d) / (10 ^ #d)) 374 | or ("%03d%s"):format(#n, n) 375 | end 376 | 377 | local tuples = {} 378 | for i, f in ipairs(filenames) do 379 | tuples[i] = {f:lower():gsub("0*(%d+)%.?(%d*)", padnum), f} 380 | end 381 | table.sort(tuples, function(a, b) 382 | return a[1] == b[1] and #b[2] < #a[2] or a[1] < b[1] 383 | end) 384 | for i, tuple in ipairs(tuples) do filenames[i] = tuple[2] end 385 | return filenames 386 | end 387 | 388 | local function alphanumsort(filenames) 389 | local is_ffi_loaded = pcall(require, "ffi") 390 | if is_windows and is_ffi_loaded then 391 | alphanumsort_windows(filenames) 392 | else 393 | alphanumsort_lua(filenames) 394 | end 395 | end 396 | 397 | local function create_playlist(dir) 398 | local pl_list = {} 399 | local file_list = utils.readdir(dir, 'files') 400 | for i = 1, #file_list do 401 | local file = file_list[i] 402 | local ext = file:match('%.([^./]+)$') 403 | if ext and exclude(ext:lower()) then 404 | table.insert(pl_list, file) 405 | msg.verbose("Adding " .. file) 406 | end 407 | end 408 | alphanumsort(pl_list) 409 | return pl_list 410 | end 411 | 412 | local function get_playlist() 413 | local pl_list = {} 414 | local playlist = mp.get_property_native("playlist") 415 | for i = 0, #playlist - 1 do 416 | local filename = mp.get_property("playlist/" .. i .. "/filename") 417 | local _, file = utils.split_path(filename) 418 | table.insert(pl_list, file) 419 | end 420 | return pl_list 421 | end 422 | 423 | -- get the index of the wanted file playlist 424 | -- if there is no playlist, return nil 425 | local function get_playlist_idx(dst_file) 426 | if dst_file == nil or dst_file == " " then 427 | return nil 428 | end 429 | 430 | local idx = nil 431 | for i = 1, #pl_list do 432 | if (dst_file == pl_list[i]) then 433 | idx = i 434 | return idx 435 | end 436 | end 437 | return idx 438 | end 439 | 440 | local function jump_resume() 441 | mp.unregister_event(jump_resume) 442 | show_message(texts.msg1, 2) 443 | end 444 | 445 | local function unbind_key() 446 | msg.verbose('Unbinding keys') 447 | wait_jump_timer:kill() 448 | mp.remove_key_binding('key_jump') 449 | mp.remove_key_binding('key_cancel') 450 | end 451 | 452 | local function key_jump() 453 | on_key = true 454 | wait_jump_timer:kill() 455 | unbind_key() 456 | current_idx = pl_idx 457 | mp.register_event('file-loaded', jump_resume) 458 | msg.verbose('Jumping to ' .. pl_path) 459 | mp.commandv('loadfile', pl_path) 460 | end 461 | 462 | local function key_cancel() 463 | on_key = true 464 | wait_jump_timer:kill() 465 | unbind_key() 466 | end 467 | 468 | local function bind_key() 469 | mp.add_forced_key_binding('1', 'key_jump', key_jump) 470 | mp.add_forced_key_binding('0', 'key_cancel', key_cancel) 471 | end 472 | 473 | -- creat a .history file 474 | local function record_history() 475 | if not o.enabled or not file_loaded then return end 476 | refresh_globals() 477 | if not path or is_protocol(path) then return end 478 | get_bookmark_path(dir) 479 | local eof = mp.get_property_bool("eof-reached") 480 | local percent_pos = mp.get_property_number("percent-pos", 0) 481 | if not eof and percent_pos < 90 then 482 | if fname ~= nil then 483 | local file = io.open(bookmark_path, "w") 484 | file:write(fname .. "\n") 485 | file:close() 486 | end 487 | else 488 | local file = io.open(bookmark_path, "w") 489 | file:write(" " .. "\n") 490 | file:close() 491 | end 492 | end 493 | 494 | local timeout = o.timeout 495 | local function wait_jumping() 496 | timeout = timeout - 1 497 | if timeout > 0 then 498 | if not on_key then 499 | local msg = string.format("%s -- %s? (%s) %02d", wait_msg, texts.msg2, texts.msg3, timeout) 500 | show_message(msg, 1) 501 | bind_key() 502 | else 503 | timeout = 0 504 | wait_jump_timer:kill() 505 | unbind_key() 506 | end 507 | else 508 | wait_jump_timer:kill() 509 | unbind_key() 510 | end 511 | end 512 | 513 | -- record the file name when video is paused 514 | -- and stop the timer 515 | local function pause(_, paused) 516 | if paused then 517 | timer4saving_history:stop() 518 | record_history() 519 | else 520 | timer4saving_history:resume() 521 | end 522 | end 523 | 524 | -- main function of the file 525 | local function record() 526 | if not o.enabled then return end 527 | refresh_globals() 528 | if pl_count and pl_count < 1 then return end 529 | if not path or is_protocol(path) or not file_exist(path) then return end 530 | if not dir or not fname then return end 531 | get_bookmark_path(dir) 532 | included_dir_count = tablelength(o.included_dir) 533 | if included_dir_count > 0 then 534 | if not need_ignore(o.included_dir, dir) then return end 535 | end 536 | if need_ignore(o.excluded_dir, dir) then return end 537 | 538 | msg.verbose('folder -- ' .. dir) 539 | msg.verbose('playing -- ' .. fname) 540 | msg.verbose('bookmark path -- ' .. bookmark_path) 541 | 542 | if (not file_exist(bookmark_path)) then 543 | pl_name = nil 544 | return 545 | else 546 | pl_name = get_record(bookmark_path) 547 | pl_path = utils.join_path(dir, pl_name) 548 | end 549 | 550 | if o.use_playlist or pl_count > 1 then 551 | pl_list = get_playlist() 552 | else 553 | pl_list = create_playlist(dir) 554 | end 555 | 556 | pl_idx = get_playlist_idx(pl_name) 557 | if (pl_idx == nil) then 558 | msg.verbose('Playlist not found. Creating a new one...') 559 | else 560 | msg.verbose('playlist index --' .. pl_idx) 561 | end 562 | 563 | current_idx = get_playlist_idx(fname) 564 | if current_idx then msg.verbose('current index -- ' .. current_idx) end 565 | 566 | if current_idx and (pl_idx == nil) then 567 | pl_idx = current_idx 568 | pl_name = fname 569 | pl_path = path 570 | elseif current_idx and (pl_idx ~= current_idx) then 571 | wait_msg = pl_idx 572 | msg.verbose('Last watched episode -- ' .. wait_msg) 573 | wait_jump_timer = mp.add_periodic_timer(1, wait_jumping) 574 | end 575 | timer4saving_history = mp.add_periodic_timer(o.save_period, record_history) 576 | mp.observe_property("pause", "bool", pause) 577 | end 578 | 579 | mp.register_event('file-loaded', function() 580 | file_loaded = true 581 | local path = mp.get_property("path") 582 | if not is_protocol(path) then 583 | path = normalize(path) 584 | directory = utils.split_path(path) 585 | else 586 | directory = nil 587 | end 588 | if directory ~= nil and directory ~= dir then 589 | mp.add_timeout(0.5, record) 590 | end 591 | end) 592 | 593 | mp.add_hook("on_unload", 50, function() 594 | mp.unobserve_property(pause) 595 | record_history() 596 | file_loaded = false 597 | end) 598 | -------------------------------------------------------------------------------- /mpv-animated.lua: -------------------------------------------------------------------------------- 1 | -- Original by Scheliux, Dragoner7 which was ported from Ruin0x11 2 | -- Adapted to webp by DonCanjas 3 | -- Modify_: https://github.com/dyphire/mpv-scripts 4 | 5 | -- Create animated webps or gifs with mpv 6 | -- Requires ffmpeg. 7 | -- Adapted from https://github.com/Scheliux/mpv-gif-generator 8 | -- Usage: "w" to set start frame, "W" to set end frame, "Ctrl+w" to create. 9 | 10 | -- Note: 11 | -- Requires FFmpeg in PATH environment variable or edit ffmpeg_path in the script options, 12 | -- Note: 13 | -- A small circle at the top-right corner is a sign that creat is happenning now. 14 | 15 | require 'mp.options' 16 | local msg = require 'mp.msg' 17 | local utils = require "mp.utils" 18 | 19 | local options = { 20 | type = "gif", -- gif or webp 21 | ffmpeg_path = "ffmpeg", 22 | dir = "~~desktop/", 23 | rez = 600, 24 | fps = 15, 25 | lossless = 0, 26 | quality = 90, 27 | compression_level = 6, 28 | loop = 0, 29 | } 30 | 31 | read_options(options) 32 | 33 | 34 | local fps 35 | local ext 36 | local text 37 | 38 | if options.type == "webp" then 39 | ext = "webp" 40 | text = "webP" 41 | else 42 | ext = "gif" 43 | text = "GIF" 44 | end 45 | 46 | -- Check for invalid fps values 47 | -- Can you believe Lua doesn't have a proper ternary operator in the year of our lord 2020? 48 | if options.fps ~= nil and options.fps >= 1 and options.fps < 30 then 49 | fps = options.fps 50 | else 51 | fps = 15 52 | end 53 | 54 | -- Set this to the filters to pass into ffmpeg's -vf option. 55 | -- filters="fps=24,scale=320:-1:flags=spline" 56 | filters=string.format("fps=%s,scale='trunc(ih*dar/2)*2:trunc(ih/2)*2',setsar=1/1,scale=%s:-1:flags=lanczos", fps, options.rez) 57 | 58 | local is_windows = package.config:sub(1, 1) == "\\" -- detect path separator, windows uses backslashes 59 | -- Setup output directory 60 | local output_directory = mp.command_native({ "expand-path", options.dir }) 61 | --create output_directory if it doesn't exist 62 | if output_directory ~= '' then 63 | local meta, meta_error = utils.file_info(output_directory) 64 | if not meta or not meta.is_dir then 65 | local windows_args = { 'powershell', '-NoProfile', '-Command', 'mkdir', string.format("\"%s\"", output_directory) } 66 | local unix_args = { 'mkdir', '-p', output_directory } 67 | local args = is_windows and windows_args or unix_args 68 | local res = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args}) 69 | if res.status ~= 0 then 70 | msg.error("Failed to create animated_dir save directory "..output_directory..". Error: "..(res.error or "unknown")) 71 | return 72 | end 73 | end 74 | end 75 | 76 | start_time = -1 77 | end_time = -1 78 | 79 | local function is_protocol(path) 80 | return type(path) == 'string' and (path:find('^%a[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= nil) 81 | end 82 | 83 | 84 | function make_animated_with_subtitles() 85 | make_animated_internal(true) 86 | end 87 | 88 | function make_animated() 89 | make_animated_internal(false) 90 | end 91 | 92 | function table_length(t) 93 | local count = 0 94 | for _ in pairs(t) do count = count + 1 end 95 | return count 96 | end 97 | 98 | 99 | function make_animated_internal(burn_subtitles) 100 | local start_time_l = start_time 101 | local end_time_l = end_time 102 | if start_time_l == -1 or end_time_l == -1 or start_time_l >= end_time_l then 103 | mp.osd_message("Invalid start/end time.") 104 | return 105 | end 106 | 107 | local trim_filters = filters 108 | local position = start_time_l 109 | local duration = end_time_l - start_time_l 110 | local filename = mp.get_property("filename/no-ext") 111 | 112 | msg.info("Creating " .. text) 113 | mp.osd_message("Creating " .. text) 114 | 115 | -- shell escape 116 | function esc_for_sub(s) 117 | s = string.gsub(s, "\\", "/") 118 | s = string.gsub(s, '"', '\\"') 119 | s = string.gsub(s, ":", "\\:") 120 | s = string.gsub(s, "'", "\\'") 121 | s = string.gsub(s, "%[", "\\%[") 122 | s = string.gsub(s, "%]", "\\%]") 123 | return s 124 | end 125 | 126 | local pathname = mp.get_property("path", "") 127 | local path = mp.get_property_native("path") 128 | local cache = mp.get_property_native("cache") 129 | local cache_state = mp.get_property_native("demuxer-cache-state") 130 | local cache_ranges = cache_state and cache_state["seekable-ranges"] or {} 131 | if path and is_protocol(path) or cache == "auto" and #cache_ranges > 0 then 132 | local pid = mp.get_property_native('pid') 133 | local temp_path = os.getenv("TEMP") or "/tmp/" 134 | local temp_video_file = utils.join_path(temp_path, "mpv_dump_" .. pid .. ".mkv") 135 | mp.commandv("dump-cache", start_time_l, end_time_l, temp_video_file) 136 | position = 0 137 | filename = mp.get_property("media-title") 138 | pathname = temp_video_file 139 | end 140 | 141 | if burn_subtitles then 142 | -- Determine currently active sub track 143 | 144 | local i = 0 145 | local tracks_count = mp.get_property_number("track-list/count") 146 | local subs_array = {} 147 | 148 | -- check for subtitle tracks 149 | 150 | while i < tracks_count do 151 | local type = mp.get_property(string.format("track-list/%d/type", i)) 152 | local selected = mp.get_property(string.format("track-list/%d/selected", i)) 153 | local external = mp.get_property(string.format("track-list/%d/external", i)) 154 | 155 | -- if it's a sub track, save it 156 | 157 | if type == "sub" then 158 | local length = table_length(subs_array) 159 | if selected == "yes" and external == "yes" then 160 | msg.info("Error: external subtitles have been selected") 161 | mp.osd_message("Error: external subtitles have been selected", 2) 162 | return 163 | else 164 | subs_array[length] = selected == "yes" 165 | end 166 | end 167 | i = i + 1 168 | end 169 | 170 | if table_length(subs_array) > 0 then 171 | 172 | local correct_track = 0 173 | 174 | -- iterate through saved subtitle tracks until the correct one is found 175 | 176 | for index, is_selected in pairs(subs_array) do 177 | if (is_selected) then 178 | correct_track = index 179 | end 180 | end 181 | 182 | trim_filters = trim_filters .. string.format(",subtitles='%s':si=%s", esc_for_sub(pathname), correct_track) 183 | 184 | end 185 | 186 | end 187 | 188 | -- make the animated 189 | local file_path = utils.join_path(output_directory, filename) 190 | 191 | -- increment filename 192 | for i = 0, 999 do 193 | local fn = string.format('%s_%03d.%s', file_path, i, ext) 194 | if not file_exists(fn) then 195 | animated_name = fn 196 | break 197 | end 198 | end 199 | if not animated_name then 200 | mp.osd_message('No available filenames!') 201 | return 202 | end 203 | 204 | local copyts = "" 205 | 206 | if burn_subtitles then 207 | copyts = "-copyts" 208 | end 209 | 210 | if options.type == "webp" then 211 | arg = string.format("%s -y -hide_banner -loglevel error -ss %s %s -t %s -i '%s' -lavfi %s -lossless %s -q:v %s -compression_level %s -loop %s '%s'", options.ffmpeg_path, position, copyts, duration, pathname, trim_filters, options.lossless, options.quality, options.compression_level, options.loop, animated_name) 212 | else 213 | arg = string.format("%s -y -hide_banner -loglevel error -ss %s %s -t %s -i '%s' -lavfi %s,'split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse' -loop %s '%s'", options.ffmpeg_path, position, copyts, duration, pathname, trim_filters, options.loop, animated_name) 214 | end 215 | local windows_args = { 'powershell', '-NoProfile', '-Command', arg } 216 | local unix_args = { '/bin/bash', '-c', arg } 217 | local args = is_windows and windows_args or unix_args 218 | local screenx, screeny, aspect = mp.get_osd_size() 219 | mp.set_osd_ass(screenx, screeny, "{\\an9}● ") 220 | local res = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args}) 221 | mp.set_osd_ass(screenx, screeny, "") 222 | if res.status ~= 0 then 223 | msg.info("Failed to creat " .. animated_name) 224 | mp.osd_message("Error creating " .. text .. ", check console for more info.") 225 | return 226 | end 227 | msg.info(animated_name .. " created.") 228 | mp.osd_message(text .. " created.") 229 | end 230 | 231 | function set_animated_start() 232 | start_time = mp.get_property_number("time-pos", -1) 233 | mp.osd_message(text .. " Start: " .. start_time) 234 | end 235 | 236 | function set_animated_end() 237 | end_time = mp.get_property_number("time-pos", -1) 238 | mp.osd_message(text .. " End: " .. end_time) 239 | end 240 | 241 | function file_exists(name) 242 | local f = io.open(name, "r") 243 | if f ~= nil then io.close(f) return true else return false end 244 | end 245 | 246 | mp.add_key_binding("w", "set_animated_start", set_animated_start) 247 | mp.add_key_binding("W", "set_animated_end", set_animated_end) 248 | mp.add_key_binding("Ctrl+w", "make_animated", make_animated) 249 | mp.add_key_binding("Ctrl+W", "make_animated_with_subtitles", make_animated_with_subtitles) --only works with srt for now 250 | -------------------------------------------------------------------------------- /mpv-torrserver.lua: -------------------------------------------------------------------------------- 1 | -- Install [Torrserver](https://github.com/YouROK/TorrServer) 2 | -- then add "script-opts-append=mpv_torrserver-server=http://[TorrServer ip]:[port]" to mpv.conf 3 | local utils = require 'mp.utils' 4 | 5 | local opts = { 6 | server = "http://localhost:8090", 7 | torrserver_init = false, 8 | torrserver_path = "TorrServer", 9 | search_for_external_tracks = true 10 | } 11 | 12 | (require 'mp.options').read_options(opts) 13 | local luacurl_available, cURL = pcall(require, 'cURL') 14 | 15 | local is_windows = package.config:sub(1, 1) == "\\" -- detect path separator, windows uses backslashes 16 | 17 | local function find_executable(name) 18 | local os_path = os.getenv("PATH") or "" 19 | local fallback_path = utils.join_path("/usr/bin", name) 20 | local exec_path 21 | for path in os_path:gmatch("[^:]+") do 22 | exec_path = utils.join_path(path, name) 23 | local meta, meta_error = utils.file_info(exec_path) 24 | if meta and meta.is_file then 25 | return exec_path 26 | end 27 | end 28 | if not is_windows then return fallback_path end 29 | return name -- fallback to just the name, hoping it's in PATH 30 | end 31 | 32 | local function init() 33 | local exec_path = find_executable(opts.torrserver_path) 34 | local windows_args = { 'powershell', '-NoProfile', '-Command', exec_path } 35 | local unix_args = { '/bin/bash', '-c', exec_path } 36 | local args = is_windows and windows_args or unix_args 37 | local res = mp.command_native_async({ name = "subprocess", capture_stdout = true, playback_only = false, args = args }) 38 | if res.status == 0 then 39 | mp.msg.error("TorrServer failed to start: ") 40 | end 41 | end 42 | 43 | local char_to_hex = function(c) 44 | return string.format("%%%02X", string.byte(c)) 45 | end 46 | 47 | local function urlencode(url) 48 | if url == nil then 49 | return 50 | end 51 | url = url:gsub("\n", "\r\n") 52 | url = url:gsub("([^%w ])", char_to_hex) 53 | url = url:gsub(" ", "+") 54 | return url 55 | end 56 | 57 | local function get_magnet_info(url) 58 | local info_url = opts.server .. "/stream?stat&link=" .. urlencode(url) 59 | local res 60 | if not (luacurl_available) then 61 | -- if Lua-cURL is not available on this system 62 | local curl_cmd = { 63 | "curl", 64 | "-L", 65 | "--silent", 66 | "--max-time", "10", 67 | info_url 68 | } 69 | local cmd = mp.command_native { 70 | name = "subprocess", 71 | capture_stdout = true, 72 | playback_only = false, 73 | args = curl_cmd 74 | } 75 | res = cmd.stdout 76 | else 77 | -- otherwise use Lua-cURL (binding to libcurl) 78 | local buf = {} 79 | local c = cURL.easy_init() 80 | c:setopt_followlocation(1) 81 | c:setopt_url(info_url) 82 | c:setopt_writefunction(function(chunk) 83 | table.insert(buf, chunk); 84 | return true; 85 | end) 86 | c:perform() 87 | res = table.concat(buf) 88 | end 89 | if res and res ~= "" then 90 | return (require 'mp.utils').parse_json(res) 91 | else 92 | return nil, "no info response (timeout?)" 93 | end 94 | end 95 | 96 | local function edlencode(url) 97 | return "%" .. string.len(url) .. "%" .. url 98 | end 99 | 100 | local function guess_type_by_extension(ext) 101 | if ext == "mkv" or ext == "mp4" or ext == "avi" or ext == "wmv" or ext == "vob" or ext == "m2ts" or ext == "ogm" then 102 | return "video" 103 | end 104 | if ext == "mka" or ext == "mp3" or ext == "aac" or ext == "flac" or ext == "ogg" or ext == "wma" or ext == "mpg" 105 | or ext == "wav" or ext == "wv" or ext == "opus" or ext == "ac3" then 106 | return "audio" 107 | end 108 | if ext == "ass" or ext == "srt" or ext == "vtt" then 109 | return "sub" 110 | end 111 | return "other"; 112 | end 113 | 114 | local function string_replace(str, match, replace) 115 | local s, e = string.find(str, match, 1, true) 116 | if s == nil or e == nil then 117 | return str 118 | end 119 | return string.sub(str, 1, s - 1) .. replace .. string.sub(str, e + 1) 120 | end 121 | 122 | -- https://github.com/mpv-player/mpv/blob/master/DOCS/edl-mpv.rst 123 | local function generate_m3u(magnet_uri, files) 124 | for _, fileinfo in ipairs(files) do 125 | -- strip top directory 126 | if fileinfo.path:find("/", 1, true) then 127 | fileinfo.fullpath = string.sub(fileinfo.path, fileinfo.path:find("/", 1, true) + 1) 128 | else 129 | fileinfo.fullpath = fileinfo.path 130 | end 131 | fileinfo.path = {} 132 | for w in fileinfo.fullpath:gmatch("([^/]+)") do table.insert(fileinfo.path, w) end 133 | local ext = string.match(fileinfo.path[#fileinfo.path], "%.(%w+)$") 134 | fileinfo.type = guess_type_by_extension(ext) 135 | end 136 | table.sort(files, function(a, b) 137 | -- make top-level files appear first in the playlist 138 | if (#a.path == 1 or #b.path == 1) and #a.path ~= #b.path then 139 | return #a.path < #b.path 140 | end 141 | -- make videos first 142 | if (a.type == "video" or b.type == "video") and a.type ~= b.type then 143 | return a.type == "video" 144 | end 145 | -- otherwise sort by path 146 | return a.fullpath < b.fullpath 147 | end); 148 | 149 | local infohash = magnet_uri:match("^magnet:%?xt=urn:bt[im]h:(%w+)") or urlencode(magnet_uri) 150 | 151 | local playlist = { '#EXTM3U' } 152 | 153 | for _, fileinfo in ipairs(files) do 154 | if fileinfo.processed ~= true then 155 | table.insert(playlist, '#EXTINF:0,' .. fileinfo.fullpath) 156 | local basename = string.match(fileinfo.path[#fileinfo.path], '^(.+)%.%w+$') 157 | 158 | local url = opts.server .. "/stream/" .. urlencode(fileinfo.fullpath) .."?play&index=" .. fileinfo.id .. "&link=" .. infohash 159 | local hdr = { "!new_stream", "!no_clip", 160 | --"!track_meta,title=" .. edlencode(basename), 161 | edlencode(url) 162 | } 163 | local edl = "edl://" .. table.concat(hdr, ";") .. ";" 164 | local external_tracks = 0 165 | 166 | fileinfo.processed = true 167 | if opts.search_for_external_tracks and basename ~= nil and fileinfo.type == "video" then 168 | mp.msg.info("!" .. basename) 169 | 170 | for _, fileinfo2 in ipairs(files) do 171 | if #fileinfo2.path > 0 and 172 | fileinfo2.type ~= "other" and 173 | fileinfo2.processed ~= true and 174 | string.find(fileinfo2.path[#fileinfo2.path], basename, 1, true) ~= nil 175 | then 176 | mp.msg.info("->" .. fileinfo2.fullpath) 177 | local title = string_replace(fileinfo2.fullpath, basename, "%") 178 | local url = opts.server .. "/stream/" .. urlencode(fileinfo2.fullpath).."?play&index=" .. fileinfo2.id .. "&link=" .. infohash 179 | local hdr = { "!new_stream", "!no_clip", "!no_chapters", 180 | "!delay_open,media_type=" .. fileinfo2.type, 181 | "!track_meta,title=" .. edlencode(title), 182 | edlencode(url) 183 | } 184 | edl = edl .. table.concat(hdr, ";") .. ";" 185 | fileinfo2.processed = true 186 | external_tracks = external_tracks + 1 187 | end 188 | end 189 | end 190 | if external_tracks == 0 then -- dont use edl 191 | table.insert(playlist, url) 192 | else 193 | table.insert(playlist, edl) 194 | end 195 | end 196 | end 197 | return table.concat(playlist, '\n') 198 | end 199 | 200 | mp.add_hook("on_load", 5, function() 201 | local url = mp.get_property("stream-open-filename") 202 | if url:find("^magnet:") == 1 or (url:find("^https?://") == 1 and url:find("%.torrent$") ~= nil) then 203 | mp.set_property_bool("file-local-options/ytdl", false) 204 | if opts.torrserver_init then init() end 205 | local magnet_info, err = get_magnet_info(url) 206 | if type(magnet_info) == "table" then 207 | if magnet_info.file_stats then 208 | -- torrent has multiple files. open as playlist 209 | mp.set_property("stream-open-filename", "memory://" .. generate_m3u(url, magnet_info.file_stats)) 210 | return 211 | end 212 | -- if not a playlist and has a name 213 | if magnet_info.name then 214 | mp.set_property("stream-open-filename", "memory://#EXTM3U\n" .. 215 | "#EXTINF:0," .. magnet_info.name .. "\n" .. 216 | opts.server .. "/stream?play&index=1&link=" .. urlencode(url)) 217 | return 218 | end 219 | else 220 | mp.msg.warn("error: " .. err) 221 | end 222 | mp.set_property("stream-open-filename", opts.server .. "/stream?m3u&link=" .. urlencode(url)) 223 | end 224 | end) 225 | -------------------------------------------------------------------------------- /open_dialog.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2022-2024 dyphire 2 | -- License: MIT 3 | -- link: https://github.com/dyphire/mpv-scripts 4 | 5 | --[[ 6 | The script calls up a window in mpv to quickly load the folder/files/iso/clipboard (support url)/other subtitles/other audio tracks/other video tracks. 7 | Usage, add bindings to input.conf: 8 | key script-message-to open_dialog import_folder 9 | key script-message-to open_dialog import_files 10 | key script-message-to open_dialog import_files # vid, aid, sid (video/audio/subtitle track) 11 | key script-message-to open_dialog import_clipboard 12 | key script-message-to open_dialog import_clipboard # vid, aid, sid (video/audio/subtitle track) 13 | key script-message-to open_dialog set_clipboard # text can be mpv properties as ${path} 14 | 15 | Also supports open dialog to select folder/files for other scripts. 16 | Scripting Example: 17 | -- open a folder select dialog 18 | mp.commandv('script-message-to', 'open_dialog', 'select_folder', mp.get_script_name()) 19 | -- receive the selected folder reply 20 | mp.register_script_message('select_folder_reply', function(folder_path) 21 | if folder_path and folder_path ~= '' then 22 | -- do something with folder_path 23 | end 24 | end) 25 | -- open a xml file select dialog 26 | mp.commandv('script-message-to', 'open_dialog', 'select_files', mp.get_script_name(), 'XML File|*.xml') 27 | -- receive the selected files reply 28 | mp.register_script_message('select_files_reply', function(file_paths) 29 | for i, file_path in ipairs(utils.parse_json(file_paths)) do 30 | -- do something with file_path 31 | end 32 | end) 33 | 34 | ]]-- 35 | 36 | local msg = require 'mp.msg' 37 | local utils = require 'mp.utils' 38 | local options = require 'mp.options' 39 | 40 | o = { 41 | video_types = '3g2,3gp,asf,avi,f4v,flv,h264,h265,m2ts,m4v,mkv,mov,mp4,mp4v,mpeg,mpg,ogm,ogv,rm,rmvb,ts,vob,webm,wmv,y4m', 42 | audio_types = 'aac,ac3,aiff,ape,au,cue,dsf,dts,flac,m4a,mid,midi,mka,mp3,mp4a,oga,ogg,opus,spx,tak,tta,wav,weba,wma,wv', 43 | image_types = 'apng,avif,bmp,gif,j2k,jp2,jfif,jpeg,jpg,jxl,mj2,png,svg,tga,tif,tiff,webp', 44 | subtitle_types = 'aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,sbv,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt', 45 | playlist_types = 'm3u,m3u8,pls,cue', 46 | iso_types = 'iso', 47 | } 48 | options.read_options(o) 49 | 50 | local function split(input) 51 | local ret = {} 52 | for str in string.gmatch(input, "([^,]+)") do 53 | ret[#ret + 1] = string.format("*.%s", str) 54 | end 55 | return ret 56 | end 57 | 58 | -- pre-defined file types 59 | local file_types = { 60 | video = table.concat(split(o.video_types), ';'), 61 | audio = table.concat(split(o.audio_types), ';'), 62 | image = table.concat(split(o.image_types), ';'), 63 | iso = table.concat(split(o.iso_types), ';'), 64 | subtitle = table.concat(split(o.subtitle_types), ';'), 65 | playlist = table.concat(split(o.playlist_types), ';'), 66 | } 67 | 68 | local powershell = nil 69 | 70 | local function pwsh_check() 71 | local arg = {"cmd", "/c", "pwsh", "--version"} 72 | local res = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = arg}) 73 | if res.status ~= 0 or res.stdout:match("^PowerShell") == nil then 74 | powershell = "powershell" 75 | else 76 | powershell = "pwsh" 77 | end 78 | end 79 | 80 | -- escapes a string so that it can be inserted into powershell as a string literal 81 | local function escape_powershell(str) 82 | if not str then return '""' end 83 | str = str:gsub('[$"`]', '`%1'):gsub('“', '`%1'):gsub('”', '`%1') 84 | return '"'..str..'"' 85 | end 86 | 87 | local function end_file(event) 88 | mp.unregister_event(end_file) 89 | if event["reason"] == "eof" or event["reason"] == "stop" or event["reason"] == "error" then 90 | local bd_device = mp.get_property_native("bluray-device") 91 | local dvd_device = mp.get_property_native("dvd-device") 92 | if event["reason"] == "error" and bd_device and bd_device ~= "" then 93 | loaded_fail = true 94 | else 95 | loaded_fail = false 96 | end 97 | if bd_device then mp.set_property("bluray-device", "") end 98 | if dvd_device then mp.set_property("dvd-device", "") end 99 | end 100 | end 101 | 102 | -- open bluray iso or dir 103 | local function open_bluray(path) 104 | mp.set_property('bluray-device', path) 105 | mp.commandv('loadfile', 'bd://') 106 | end 107 | 108 | -- open dvd iso or dir 109 | local function open_dvd(path) 110 | mp.set_property('dvd-device', path) 111 | mp.commandv('loadfile', 'dvd://') 112 | end 113 | 114 | -- open folder 115 | local function open_folder(path, i) 116 | local fpath, dir = utils.split_path(path) 117 | if utils.file_info(utils.join_path(path, "BDMV")) then 118 | open_bluray(path) 119 | elseif utils.file_info(utils.join_path(path, "VIDEO_TS")) then 120 | open_dvd(path) 121 | elseif dir:upper() == "BDMV" then 122 | open_bluray(fpath) 123 | elseif dir:upper() == "VIDEO_TS" then 124 | open_dvd(fpath) 125 | else 126 | mp.commandv('loadfile', path, i == 1 and 'replace' or 'append') 127 | end 128 | end 129 | 130 | -- open files 131 | local function open_files(path, type, i, is_clip) 132 | local ext = string.match(path, "%.([^%.]+)$"):lower() 133 | if file_types['subtitle']:match(ext) then 134 | mp.commandv('sub-add', path, 'cached') 135 | elseif type == 'vid' and (not is_clip or (file_types['video']:match(ext) or file_types['image']:match(ext))) then 136 | mp.commandv('video-add', path, 'cached') 137 | elseif type == 'aid' and (not is_clip or file_types['audio']:match(ext)) then 138 | mp.commandv('audio-add', path, 'cached') 139 | elseif file_types['iso']:match(ext) then 140 | local idle = mp.get_property('idle') 141 | if idle ~= 'yes' then mp.set_property('idle', 'yes') end 142 | mp.register_event("end-file", end_file) 143 | open_bluray(path) 144 | mp.add_timeout(1.0, function() 145 | if idle ~= 'yes' then mp.set_property('idle', idle) end 146 | if loaded_fail then 147 | loaded_fail = false 148 | open_dvd(path) 149 | end 150 | end) 151 | else 152 | mp.commandv('loadfile', path, i == 1 and 'replace' or 'append') 153 | end 154 | end 155 | 156 | local function select_folder() 157 | if not powershell then pwsh_check() end 158 | local powershell_script = string.format([[&{ 159 | Add-Type -AssemblyName System.Windows.Forms 160 | $fbd = New-Object System.Windows.Forms.FolderBrowserDialog 161 | $fbd.RootFolder = "Desktop" 162 | $fbd.ShowNewFolderButton = $true 163 | $owner = New-Object System.Windows.Forms.NativeWindow 164 | $owner.AssignHandle((Get-Process -Id %d).MainWindowHandle) 165 | try { 166 | if ($fbd.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) { 167 | $u8 = [System.Text.Encoding]::UTF8 168 | $out = [Console]::OpenStandardOutput() 169 | $selectedFolder = $fbd.SelectedPath 170 | $u8selectedFolder = $u8.GetBytes("$selectedFolder`n") 171 | $out.Write($u8selectedFolder, 0, $u8selectedFolder.Length) 172 | } 173 | } finally { 174 | $owner.ReleaseHandle() 175 | $fbd.Dispose() 176 | } 177 | }]], mp.get_property_number('pid')) 178 | local res = mp.command_native({ 179 | name = 'subprocess', 180 | playback_only = false, 181 | capture_stdout = true, 182 | args = { powershell, '-NoProfile', '-Command', powershell_script }, 183 | }) 184 | if res.status ~= 0 then 185 | mp.osd_message("Failed to open folder dialog.") 186 | return nil 187 | end 188 | local folder_path = res.stdout:match("(.-)[\r\n]?$") -- Trim any trailing newline 189 | return folder_path 190 | end 191 | 192 | local function select_files(filter) 193 | if not powershell then pwsh_check() end 194 | local powershell_script = string.format([[&{ 195 | Add-Type -AssemblyName System.Windows.Forms 196 | $ofd = New-Object System.Windows.Forms.OpenFileDialog 197 | $ofd.Multiselect = $true 198 | $ofd.Filter = %s 199 | $owner = New-Object System.Windows.Forms.NativeWindow 200 | $owner.AssignHandle((Get-Process -Id %d).MainWindowHandle) 201 | try { 202 | if ($ofd.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) { 203 | $u8 = [System.Text.Encoding]::UTF8 204 | $out = [Console]::OpenStandardOutput() 205 | ForEach ($filename in $ofd.FileNames) { 206 | $u8filename = $u8.GetBytes("$filename`n") 207 | $out.Write($u8filename, 0, $u8filename.Length) 208 | } 209 | } 210 | } finally { 211 | $owner.ReleaseHandle() 212 | $ofd.Dispose() 213 | } 214 | }]], escape_powershell(filter), mp.get_property_number('pid')) 215 | local res = mp.command_native({ 216 | name = 'subprocess', 217 | playback_only = false, 218 | capture_stdout = true, 219 | args = { powershell, '-NoProfile', '-Command', powershell_script }, 220 | }) 221 | local file_paths = {} 222 | if res.status ~= 0 then 223 | mp.osd_message("Failed to open files dialog.") 224 | return file_paths 225 | end 226 | for file_path in string.gmatch(res.stdout, '[^\r\n]+') do 227 | table.insert(file_paths, file_path) 228 | end 229 | return file_paths 230 | end 231 | 232 | -- import folder 233 | local function import_folder() 234 | local folder_path = select_folder() 235 | if folder_path and folder_path ~= '' then open_folder(folder_path, 1) end 236 | end 237 | 238 | -- import files 239 | local function import_files(type) 240 | local filter = '' 241 | if type == 'vid' then 242 | filter = string.format("Video Files|%s|Image Files|%s", file_types['video'], file_types['image']) 243 | elseif type == 'aid' then 244 | filter = string.format("Audio Files|%s", file_types['audio']) 245 | elseif type == 'sid' then 246 | filter = string.format("Subtitle Files|%s", file_types['subtitle']) 247 | else 248 | filter = string.format("All Files (*.*)|*.*|Video Files|%s|Audio Files|%s|Image Files|%s|ISO Files|%s|Subtitle Files|%s|Playlist Files|%s", 249 | file_types['video'], file_types['audio'], file_types['image'], file_types['iso'], file_types['subtitle'], file_types['playlist']) 250 | end 251 | for i, file_path in ipairs(select_files(filter)) do 252 | open_files(file_path, type, i, false) 253 | end 254 | end 255 | 256 | -- Returns a string of UTF-8 text from the clipboard 257 | local function get_clipboard() 258 | if mp.get_property('clipboard-backends') ~= nil or mp.get_property_bool('clipboard-enable') then 259 | return mp.get_property('clipboard/text', '') 260 | end 261 | local res = mp.command_native({ 262 | name = 'subprocess', 263 | playback_only = false, 264 | capture_stdout = true, 265 | args = { 'powershell', '-NoProfile', '-Command', [[& { 266 | Trap { 267 | Write-Error -ErrorRecord $_ 268 | Exit 1 269 | } 270 | $clip = Get-Clipboard -Raw -Format Text -TextFormatType UnicodeText 271 | if (-not $clip) { 272 | $clip = Get-Clipboard -Raw -Format FileDropList 273 | } 274 | $u8clip = [System.Text.Encoding]::UTF8.GetBytes($clip) 275 | [Console]::OpenStandardOutput().Write($u8clip, 0, $u8clip.Length) 276 | }]] } 277 | }) 278 | if not res.error then 279 | return res.stdout 280 | end 281 | return '' 282 | end 283 | 284 | -- open files from clipboard 285 | local function open_clipboard(path, type, i) 286 | local path = path:gsub("^[\'\"]", ""):gsub("[\'\"]$", ""):gsub('^%s+', ''):gsub('%s+$', '') 287 | if path:find('^%a[%w.+-]-://') then 288 | mp.commandv('loadfile', path, i == 1 and 'replace' or 'append') 289 | else 290 | local meta = utils.file_info(path) 291 | if not meta then 292 | mp.osd_message('Clipboard path is invalid') 293 | msg.warn('Clipboard path is invalid') 294 | elseif meta.is_dir then 295 | open_folder(path, i) 296 | elseif meta.is_file then 297 | open_files(path, type, i, true) 298 | else 299 | mp.osd_message('Clipboard path is invalid') 300 | msg.warn('Clipboard path is invalid') 301 | end 302 | end 303 | end 304 | 305 | -- import clipboard 306 | local function import_clipboard(type) 307 | local clip = get_clipboard() 308 | if clip ~= '' then 309 | local i = 0 310 | for path in string.gmatch(clip, '[^\r\n]+') do 311 | i = i + 1 312 | open_clipboard(path, type, i) 313 | end 314 | else 315 | mp.osd_message('Clipboard is empty') 316 | msg.warn('Clipboard is empty') 317 | end 318 | end 319 | 320 | -- sets the contents of the clipboard to the given string 321 | local function set_clipboard(text) 322 | msg.verbose('setting clipboard text:', text) 323 | if mp.get_property('clipboard-backends') ~= nil or mp.get_property_bool('clipboard-enable') then 324 | mp.commandv('set', 'clipboard/text', text) 325 | else 326 | mp.commandv('run', 'powershell', '-NoProfile', '-command', 'set-clipboard', escape_powershell(text)) 327 | end 328 | end 329 | 330 | mp.register_script_message('import_folder', import_folder) 331 | mp.register_script_message('import_files', import_files) 332 | mp.register_script_message('import_clipboard', import_clipboard) 333 | mp.register_script_message('set_clipboard', set_clipboard) 334 | mp.register_script_message('select_folder', function(script_name) 335 | local folder_path = select_folder() 336 | mp.commandv('script-message-to', script_name, 'select_folder_reply', folder_path) 337 | end) 338 | mp.register_script_message('select_files', function(script_name, filter) 339 | local file_paths = select_files(filter) 340 | mp.commandv('script-message-to', script_name, 'select_files_reply', utils.format_json(file_paths)) 341 | end) 342 | -------------------------------------------------------------------------------- /skiptosilence.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | * skiptosilence.lua v.2024-08-22 3 | * 4 | * AUTHORS: detuur, microraptor, Eisa01, dyphire 5 | * License: MIT 6 | * link: https://github.com/detuur/mpv-scripts 7 | * 8 | * This script skips to the next silence in the file. The 9 | * intended use for this is to skip until the end of an 10 | * opening sequence, at which point there's often a short 11 | * period of silence. 12 | * 13 | * The default keybind is F3. You can change this by adding 14 | * the following line to your input.conf: 15 | * KEY script-binding skip-to-silence 16 | * 17 | * In order to tweak the script parameters, you can place the 18 | * text below, between the template markers, in a new file at 19 | * script-opts/skiptosilence.conf in mpv's user folder. The 20 | * parameters will be automatically loaded on start. 21 | * 22 | * Dev note about the used filters: 23 | * - `silencedetect` is an audio filter that listens for silence and 24 | * emits text output with details whenever silence is detected. 25 | * Filter documentation: https://ffmpeg.org/ffmpeg-filters.html 26 | ****************** TEMPLATE FOR skiptosilence.conf ****************** 27 | #--(#number). Maximum amount of noise to trigger, in terms of dB. Lower is more sensitive. 28 | silence_audio_level=-40 29 | 30 | #--(#number). Duration of the silence that will be detected to trigger skipping. 31 | silence_duration=0.7 32 | 33 | #--(0/#number). The first detcted silence_duration will be ignored for the defined seconds in this option, and it will continue skipping until the next silence_duration. 34 | # (0 for disabled, or specify seconds). 35 | ignore_silence_duration=1 36 | 37 | #--(0/#number). Minimum amount of seconds accepted to skip until the configured silence_duration. 38 | # (0 for disabled, or specify seconds) 39 | min_skip_duration=0 40 | 41 | #--(0/#number). Maximum amount of seconds accepted to skip until the configured silence_duration. 42 | # (0 for disabled, or specify seconds) 43 | max_skip_duration=120 44 | 45 | #--(yes/no). Default is muted, however if audio was enabled due to custom mpv settings, the fast-forwarded audio can sound jarring. 46 | force_mute_on_skip=no 47 | 48 | ************************** END OF TEMPLATE ************************** 49 | --]] 50 | 51 | local o = { 52 | silence_audio_level = -40, 53 | silence_duration = 0.7, 54 | ignore_silence_duration=1, 55 | min_skip_duration = 0, 56 | max_skip_duration = 120, 57 | force_mute_on_skip = false, 58 | } 59 | 60 | (require 'mp.options').read_options(o) 61 | local mp = require 'mp' 62 | local msg = require 'mp.msg' 63 | 64 | speed_state = 1 65 | pause_state = false 66 | mute_state = false 67 | sub_state = nil 68 | secondary_sub_state = nil 69 | vid_state = nil 70 | geometry_state = nil 71 | skip_flag = false 72 | initial_skip_time = 0 73 | 74 | local function restoreProp(pause) 75 | if not pause then pause = pause_state end 76 | local fullscreen = mp.get_property("fullscreen") 77 | 78 | mp.set_property("vid", vid_state) 79 | if not fullscreen then 80 | mp.set_property("geometry", geometry_state) 81 | end 82 | mp.set_property_bool("mute", mute_state) 83 | mp.set_property("speed", speed_state) 84 | mp.unobserve_property(foundSilence) 85 | mp.command("no-osd af remove @skiptosilence") 86 | mp.set_property_bool("pause", pause) 87 | mp.set_property("sub-visibility", sub_state) 88 | mp.set_property("secondary-sub-visibility", secondary_sub_state) 89 | timer:kill() 90 | skip_flag = false 91 | end 92 | 93 | local function handleMinMaxDuration(timepos) 94 | if not skip_flag then return end 95 | if not timepos then timepos = mp.get_property_number("time-pos") end 96 | 97 | skip_duration = timepos - initial_skip_time 98 | if o.min_skip_duration > 0 and skip_duration <= o.min_skip_duration then 99 | restoreProp() 100 | mp.osd_message('Skipping Cancelled\nSilence is less than configured minimum') 101 | msg.info('Skipping Cancelled\nSilence is less than configured minimum') 102 | return true 103 | end 104 | if o.max_skip_duration > 0 and skip_duration >= o.max_skip_duration then 105 | restoreProp() 106 | mp.osd_message('Skipping Cancelled\nSilence is more than configured maximum') 107 | msg.info('Skipping Cancelled\nSilence is more than configured maximum') 108 | return true 109 | end 110 | return false 111 | end 112 | 113 | local function skippedMessage() 114 | mp.osd_message("Skipped to silence at " .. mp.get_property_osd("time-pos")) 115 | msg.info("Skipped to silence at " .. mp.get_property_osd("time-pos")) 116 | end 117 | 118 | function foundSilence(name, value) 119 | if value == "{}" or value == nil then 120 | return 121 | end 122 | 123 | timecode = tonumber(string.match(value, "%d+%.?%d+")) 124 | if timecode == nil or timecode < initial_skip_time + o.ignore_silence_duration then 125 | return 126 | end 127 | 128 | if handleMinMaxDuration(timecode) then return end 129 | 130 | restoreProp() 131 | 132 | mp.add_timeout(0.05, skippedMessage) 133 | skip_flag = false 134 | end 135 | 136 | local function doSkip() 137 | local audio = mp.get_property_number("aid") or 0 138 | if audio == 0 then 139 | mp.osd_message("No audio stream detected") 140 | msg.info("No audio stream detected") 141 | return 142 | end 143 | if skip_flag then return end 144 | initial_skip_time = (mp.get_property_native("time-pos") or 0) 145 | if math.floor(initial_skip_time) == math.floor(mp.get_property_native('duration') or 0) then return end 146 | 147 | local width = mp.get_property_native("osd-width") 148 | local height = mp.get_property_native("osd-height") 149 | local fullscreen = mp.get_property_native("fullscreen") 150 | geometry_state = mp.get_property("geometry") 151 | if not fullscreen then 152 | mp.set_property_native("geometry", ("%dx%d"):format(width, height)) 153 | end 154 | 155 | mp.command( 156 | "no-osd af add @skiptosilence:lavfi=[silencedetect=noise=" .. 157 | o.silence_audio_level .. "dB:d=" .. o.silence_duration .. "]" 158 | ) 159 | 160 | mp.observe_property("af-metadata/skiptosilence", "string", foundSilence) 161 | 162 | sub_state = mp.get_property("sub-visibility") 163 | mp.set_property("sub-visibility", "no") 164 | secondary_sub_state = mp.get_property("secondary-sub-visibility") 165 | mp.set_property("secondary-sub-visibility", "no") 166 | vid_state = mp.get_property("vid") 167 | mp.set_property("vid", "no") 168 | mute_state = mp.get_property_native("mute") 169 | if o.force_mute_on_skip then 170 | mp.set_property_bool("mute", true) 171 | end 172 | pause_state = mp.get_property_native("pause") 173 | mp.set_property_bool("pause", false) 174 | speed_state = mp.get_property_native("speed") 175 | mp.set_property("speed", 100) 176 | skip_flag = true 177 | 178 | timer = mp.add_periodic_timer(0.5, function() 179 | local video_time = (mp.get_property_native("time-pos") or 0) 180 | handleMinMaxDuration(video_time) 181 | end) 182 | end 183 | 184 | 185 | mp.observe_property('pause', 'bool', function(_, value) 186 | if value and skip_flag then 187 | restoreProp(true) 188 | end 189 | end) 190 | 191 | mp.observe_property('percent-pos', 'number', function(_, value) 192 | if skip_flag and value and value > 99 then 193 | local fullscreen = mp.get_property("fullscreen") 194 | mp.set_property("vid", vid_state) 195 | if not fullscreen then 196 | mp.set_property("geometry", geometry_state) 197 | end 198 | end 199 | end) 200 | 201 | mp.add_hook('on_unload', 9, function() 202 | if skip_flag then 203 | restoreProp() 204 | end 205 | end) 206 | 207 | mp.add_key_binding(nil, "skip-to-silence", doSkip) -------------------------------------------------------------------------------- /slicing_copy.lua: -------------------------------------------------------------------------------- 1 | local msg = require "mp.msg" 2 | local utils = require "mp.utils" 3 | local options = require "mp.options" 4 | 5 | local cut_pos = nil 6 | local copy_audio = true 7 | local ext_map = { 8 | ["mpegts"] = "ts", 9 | } 10 | local o = { 11 | ffmpeg_path = "ffmpeg", 12 | target_dir = "~~/cutfragments", 13 | overwrite = false, -- whether to overwrite exist files 14 | vcodec = "copy", 15 | acodec = "copy", 16 | debug = false, 17 | } 18 | 19 | options.read_options(o) 20 | 21 | Command = {} 22 | 23 | local function is_protocol(path) 24 | return type(path) == 'string' and (path:find('^%a[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= nil) 25 | end 26 | 27 | function Command:new(name) 28 | local o = {} 29 | setmetatable(o, self) 30 | self.__index = self 31 | o.name = "" 32 | o.args = { "" } 33 | if name then 34 | o.name = name 35 | o.args[1] = name 36 | end 37 | return o 38 | end 39 | function Command:arg(...) 40 | for _, v in ipairs({...}) do 41 | self.args[#self.args + 1] = v 42 | end 43 | return self 44 | end 45 | function Command:as_str() 46 | return table.concat(self.args, " ") 47 | end 48 | function Command:run() 49 | local res, err = mp.command_native({ 50 | name = "subprocess", 51 | args = self.args, 52 | capture_stdout = true, 53 | capture_stderr = true, 54 | }) 55 | return res, err 56 | end 57 | 58 | local function file_format() 59 | local fmt = mp.get_property("file-format") 60 | if not fmt:find(',') then 61 | return fmt 62 | end 63 | local path = mp.get_property('path') 64 | if is_protocol(path) then 65 | return nil 66 | end 67 | local filename = mp.get_property('filename') 68 | return filename:match('%.([^.]+)$') 69 | end 70 | 71 | local function get_ext() 72 | local fmt = file_format() 73 | if fmt and ext_map[fmt] ~= nil then 74 | return ext_map[fmt] 75 | else 76 | return fmt 77 | end 78 | end 79 | 80 | local function timestamp(duration) 81 | local hours = math.floor(duration / 3600) 82 | local minutes = math.floor(duration % 3600 / 60) 83 | local seconds = duration % 60 84 | return string.format("%02d:%02d:%06.3f", hours, minutes, seconds) 85 | end 86 | 87 | local function osd(str) 88 | return mp.osd_message(str, 3) 89 | end 90 | 91 | local function info(s) 92 | msg.info(s) 93 | osd(s) 94 | end 95 | 96 | local function get_outname(path, shift, endpos) 97 | local name = mp.get_property("filename/no-ext") 98 | if is_protocol(path) then 99 | name = mp.get_property("media-title") 100 | end 101 | local ext = get_ext() or "mkv" 102 | name = string.format("%s_%s-%s.%s", name, timestamp(shift), timestamp(endpos), ext) 103 | return name:gsub(":", "-") 104 | end 105 | 106 | local function cut(shift, endpos) 107 | local duration = endpos - shift 108 | local path = mp.get_property("path") 109 | local inpath = mp.get_property("stream-open-filename") 110 | local outpath = utils.join_path( 111 | o.target_dir, 112 | get_outname(path, shift, endpos) 113 | ) 114 | 115 | local cache = mp.get_property_native("cache") 116 | local cache_state = mp.get_property_native("demuxer-cache-state") 117 | local cache_ranges = cache_state and cache_state["seekable-ranges"] or {} 118 | if path and is_protocol(path) or cache == "auto" and #cache_ranges > 0 then 119 | local pid = mp.get_property_native('pid') 120 | local temp_path = os.getenv("TEMP") or "/tmp/" 121 | local temp_video_file = utils.join_path(temp_path, "mpv_dump_" .. pid .. ".mkv") 122 | mp.commandv("dump-cache", shift, endpos, temp_video_file) 123 | shift = 0 124 | inpath = temp_video_file 125 | end 126 | 127 | local cmds = Command:new(o.ffmpeg_path) 128 | :arg("-v", "warning") 129 | :arg(o.overwrite and "-y" or "-n") 130 | :arg("-stats") 131 | cmds:arg("-ss", tostring(shift)) 132 | cmds:arg("-accurate_seek") 133 | cmds:arg("-i", inpath) 134 | cmds:arg("-t", tostring(duration)) 135 | cmds:arg("-c:v", o.vcodec) 136 | cmds:arg("-c:a", o.acodec) 137 | cmds:arg("-c:s", "copy") 138 | cmds:arg("-map", string.format("v:%s?", math.max(mp.get_property_number("current-tracks/video/id", 0) - 1, 0))) 139 | cmds:arg("-map", string.format("a:%s?", math.max(mp.get_property_number("current-tracks/audio/id", 0) - 1, 0))) 140 | cmds:arg("-map", string.format("s:%s?", math.max(mp.get_property_number("current-tracks/sub/id", 0) - 1, 0))) 141 | cmds:arg(not copy_audio and "-an" or nil) 142 | cmds:arg("-avoid_negative_ts", "make_zero") 143 | cmds:arg("-async", "1") 144 | cmds:arg(outpath) 145 | msg.info("Run commands: " .. cmds:as_str()) 146 | local screenx, screeny, aspect = mp.get_osd_size() 147 | mp.set_osd_ass(screenx, screeny, "{\\an9}● ") 148 | local res, err = cmds:run() 149 | mp.set_osd_ass(screenx, screeny, "") 150 | if err then 151 | msg.error(utils.to_string(err)) 152 | mp.osd_message("Failed. Refer console for details.") 153 | elseif res.status ~= 0 then 154 | if res.stderr ~= "" or res.stdout ~= "" then 155 | msg.info("stderr: " .. (res.stderr:gsub("^%s*(.-)%s*$", "%1"))) -- trim stderr 156 | msg.info("stdout: " .. (res.stdout:gsub("^%s*(.-)%s*$", "%1"))) -- trim stdout 157 | mp.osd_message("Failed. Refer console for details.") 158 | end 159 | elseif res.status == 0 then 160 | if o.debug and (res.stderr ~= "" or res.stdout ~= "") then 161 | msg.info("stderr: " .. (res.stderr:gsub("^%s*(.-)%s*$", "%1"))) -- trim stderr 162 | msg.info("stdout: " .. (res.stdout:gsub("^%s*(.-)%s*$", "%1"))) -- trim stdout 163 | end 164 | msg.info("Trim file successfully created: " .. outpath) 165 | mp.add_timeout(1, function() 166 | mp.osd_message("Trim file successfully created!") 167 | end) 168 | end 169 | end 170 | 171 | local function toggle_mark() 172 | local pos, err = mp.get_property_number("time-pos") 173 | if not pos then 174 | osd("Failed to get timestamp") 175 | msg.error("Failed to get timestamp: " .. err) 176 | return 177 | end 178 | if cut_pos then 179 | local shift, endpos = cut_pos, pos 180 | if shift > endpos then 181 | shift, endpos = endpos, shift 182 | elseif shift == endpos then 183 | osd("Cut fragment is empty") 184 | return 185 | end 186 | cut_pos = nil 187 | info(string.format("Cut fragment: %s-%s", timestamp(shift), timestamp(endpos))) 188 | cut(shift, endpos) 189 | else 190 | cut_pos = pos 191 | info(string.format("Marked %s as start position", timestamp(pos))) 192 | end 193 | end 194 | 195 | local function toggle_audio() 196 | copy_audio = not copy_audio 197 | info("Audio capturing is " .. (copy_audio and "enabled" or "disabled")) 198 | end 199 | 200 | local function clear_toggle_mark() 201 | cut_pos = nil 202 | info("Cleared cut fragment") 203 | end 204 | 205 | o.target_dir = o.target_dir:gsub('"', "") 206 | local file, _ = utils.file_info(mp.command_native({ "expand-path", o.target_dir })) 207 | if not file then 208 | --create target_dir if it doesn't exist 209 | local savepath = mp.command_native({ "expand-path", o.target_dir }) 210 | local is_windows = package.config:sub(1, 1) == "\\" 211 | local windows_args = { 'powershell', '-NoProfile', '-Command', 'mkdir', string.format("\"%s\"", savepath) } 212 | local unix_args = { 'mkdir', '-p', savepath } 213 | local args = is_windows and windows_args or unix_args 214 | local res = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args}) 215 | if res.status ~= 0 then 216 | msg.error("Failed to create target_dir save directory "..savepath..". Error: "..(res.error or "unknown")) 217 | return 218 | end 219 | elseif not file.is_dir then 220 | osd("target_dir is a file") 221 | msg.warn(string.format("target_dir `%s` is a file", o.target_dir)) 222 | end 223 | o.target_dir = mp.command_native({ "expand-path", o.target_dir }) 224 | 225 | mp.add_key_binding("c", "slicing_mark", toggle_mark) 226 | mp.add_key_binding("a", "slicing_audio", toggle_audio) 227 | mp.add_key_binding("C", "clear_slicing_mark", clear_toggle_mark) 228 | -------------------------------------------------------------------------------- /sponsorblock_minimal.lua: -------------------------------------------------------------------------------- 1 | -- sponsorblock_minimal.lua v 0.5.1 2 | -- 3 | -- This script skip/mute sponsored segments of YouTube and bilibili videos 4 | -- using data from https://github.com/ajayyy/SponsorBlock 5 | -- and https://github.com/hanydd/BilibiliSponsorBlock 6 | 7 | local opt = require 'mp.options' 8 | local utils = require 'mp.utils' 9 | 10 | local options = { 11 | youtube_sponsor_server = "https://sponsor.ajay.app/api/skipSegments", 12 | bilibili_sponsor_server = "https://bsbsb.top/api/skipSegments", 13 | -- Categories to fetch 14 | -- Perform skip/mute/mark chapter based on the 'actionType' returned 15 | categories = '"sponsor"', 16 | } 17 | 18 | opt.read_options(options) 19 | 20 | local ranges = nil 21 | local video_id = nil 22 | local sponsor_server = nil 23 | local cache = {} 24 | local mute = false 25 | local ON = false 26 | 27 | local function getranges(url) 28 | local res = mp.command_native{ 29 | name = "subprocess", 30 | capture_stdout = true, 31 | playback_only = false, 32 | args = { 33 | "curl", "-L", "-s", "-g", 34 | "-H", "origin: mpv-script/sponsorblock_minimal", 35 | "-H", "x-ext-version: 0.5.1", 36 | url 37 | } 38 | } 39 | 40 | if res.status ~= 0 then 41 | return nil 42 | end 43 | 44 | return utils.parse_json(res.stdout) 45 | end 46 | 47 | local function make_chapter(ranges) 48 | local chapters_time = {} 49 | local chapters_title = {} 50 | local chapter_index = 0 51 | local all_chapters = mp.get_property_native("chapter-list") 52 | for _, v in pairs(ranges) do 53 | table.insert(chapters_time, v.segment[1]) 54 | table.insert(chapters_title, v.category) 55 | table.insert(chapters_time, v.segment[2]) 56 | table.insert(chapters_title, "normal") 57 | end 58 | 59 | for i = 1, #chapters_time do 60 | chapter_index = chapter_index + 1 61 | all_chapters[chapter_index] = { 62 | title = chapters_title[i] or ("Chapter " .. string.format("%02.f", chapter_index)), 63 | time = chapters_time[i] 64 | } 65 | end 66 | 67 | table.sort(all_chapters, function(a, b) return a['time'] < b['time'] end) 68 | mp.set_property_native("chapter-list", all_chapters) 69 | end 70 | 71 | local function skip_ads(_, pos) 72 | if pos ~= nil and ranges ~= nil then 73 | for _, v in pairs(ranges) do 74 | if v.actionType == "skip" and v.segment[1] <= pos and v.segment[2] > pos then 75 | --this message may sometimes be wrong 76 | --it only seems to be a visual thing though 77 | local time = math.floor(v.segment[2] - pos) 78 | mp.osd_message(string.format("[sponsorblock] skipping forward %ds", time)) 79 | --need to do the +0.01 otherwise mpv will start spamming skip sometimes 80 | mp.set_property("time-pos", v.segment[2] + 0.01) 81 | elseif v.actionType == "mute" then 82 | if v.segment[1] <= pos and v.segment[2] >= pos then 83 | cache[v.segment[2]] = nil 84 | mp.set_property_bool("mute", true) 85 | elseif pos > v.segment[2] and not cache[v.segment[2]] and mute ~= false then 86 | cache[v.segment[2]] = true 87 | mp.set_property_bool("mute", false) 88 | end 89 | end 90 | end 91 | end 92 | end 93 | 94 | local function file_loaded() 95 | cache = {} 96 | local video_path = mp.get_property("path", "") 97 | local video_referer = mp.get_property("http-header-fields", ""):match("[Rr]eferer:%s*([^,\r\n]+)") or "" 98 | local purl = mp.get_property("metadata/by-key/PURL", "") 99 | local bilibili = video_path:match("bilibili.com/video") or video_referer:match("bilibili.com/video") or false 100 | mute = mp.get_property_bool("mute") 101 | 102 | local urls = { 103 | "ytdl://youtu%.be/([%w-_]+).*", 104 | "ytdl://w?w?w?%.?youtube%.com/v/([%w-_]+).*", 105 | "ytdl://w?w?w?%.?bilibili%.com/video/([%w-_]+).*", 106 | "https?://youtu%.be/([%w-_]+).*", 107 | "https?://w?w?w?%.?youtube%.com/v/([%w-_]+).*", 108 | "https?://w?w?w?%.?bilibili%.com/video/([%w-_]+).*", 109 | "/watch.*[?&]v=([%w-_]+).*", 110 | "/embed/([%w-_]+).*", 111 | "^ytdl://([%w-_]+)$", 112 | "-([%w-_]+)%." 113 | } 114 | 115 | for _, url in ipairs(urls) do 116 | video_id = video_id or video_path:match(url) or video_referer:match(url) or purl:match(url) 117 | end 118 | 119 | if not video_id or string.len(video_id) < 11 then return end 120 | 121 | if bilibili then 122 | sponsor_server = options.bilibili_sponsor_server 123 | video_id = string.sub(video_id, 1, 12) 124 | else 125 | sponsor_server = options.youtube_sponsor_server 126 | video_id = string.sub(video_id, 1, 11) 127 | end 128 | 129 | local url = ("%s?videoID=%s&categories=[%s]"):format(sponsor_server, video_id, options.categories) 130 | 131 | ranges = getranges(url) 132 | if ranges ~= nil then 133 | make_chapter(ranges) 134 | ON = true 135 | mp.observe_property("time-pos", "native", skip_ads) 136 | end 137 | end 138 | 139 | local function end_file() 140 | if not ON then return end 141 | mp.unobserve_property(skip_ads) 142 | cache = nil 143 | ranges = nil 144 | ON = false 145 | end 146 | 147 | mp.register_event("file-loaded", file_loaded) 148 | mp.register_event("end-file", end_file) 149 | -------------------------------------------------------------------------------- /sub_export.lua: -------------------------------------------------------------------------------- 1 | -- SOURCE: https://github.com/kelciour/mpv-scripts/blob/master/sub-export.lua 2 | -- COMMIT: 29 Aug 2018 5039d8b 3 | -- 4 | -- Usage: 5 | -- add bindings to input.conf: 6 | -- key script-message-to sub_export export-selected-subtitles 7 | -- 8 | -- Note: 9 | -- Requires FFmpeg in PATH environment variable or edit ffmpeg_path in the script options, 10 | -- for example, by replacing "ffmpeg" with "C:\Programs\ffmpeg\bin\ffmpeg.exe" 11 | -- Note: 12 | -- The script support subtitles in srt, ass, and sup formats. 13 | -- Note: 14 | -- A small circle at the top-right corner is a sign that export is happenning now. 15 | -- Note: 16 | -- The exported subtitles will be automatically selected with visibility set to true. 17 | -- Note: 18 | -- It could take ~1-5 minutes to export subtitles. 19 | 20 | local msg = require 'mp.msg' 21 | local utils = require 'mp.utils' 22 | local options = require "mp.options" 23 | 24 | ---- Script Options ---- 25 | local o = { 26 | ffmpeg_path = "ffmpeg", 27 | -- eng=English, chs=Chinese 28 | language = "eng", 29 | } 30 | 31 | options.read_options(o) 32 | ------------------------ 33 | 34 | local is_windows = package.config:sub(1, 1) == "\\" -- detect path separator, windows uses backslashes 35 | 36 | local function export_selected_subtitles() 37 | local i = 0 38 | local tracks_count = mp.get_property_number("track-list/count") 39 | while i < tracks_count do 40 | local track_type = mp.get_property(string.format("track-list/%d/type", i)) 41 | local track_index = mp.get_property_number(string.format("track-list/%d/ff-index", i)) 42 | local track_selected = mp.get_property(string.format("track-list/%d/selected", i)) 43 | local track_title = mp.get_property(string.format("track-list/%d/title", i)) 44 | local track_lang = mp.get_property(string.format("track-list/%d/lang", i)) 45 | local track_external = mp.get_property(string.format("track-list/%d/external", i)) 46 | local track_codec = mp.get_property(string.format("track-list/%d/codec", i)) 47 | local path = mp.get_property('path') 48 | local dir, filename = utils.split_path(path) 49 | local fname = mp.get_property("filename/no-ext") 50 | local index = string.format("0:%d", track_index) 51 | 52 | if track_type == "sub" and track_selected == "yes" then 53 | if track_external == "yes" then 54 | if o.language == 'chs' then 55 | msg.info("错误:已选择外部字幕") 56 | mp.osd_message("错误:已选择外部字幕", 2) 57 | else 58 | msg.info("Error: external subtitles have been selected") 59 | mp.osd_message("Error: external subtitles have been selected", 2) 60 | end 61 | return 62 | end 63 | 64 | local video_file = utils.join_path(dir, filename) 65 | 66 | local subtitles_ext = ".srt" 67 | if string.find(track_codec, "ass") ~= nil then 68 | subtitles_ext = ".ass" 69 | elseif string.find(track_codec, "pgs") ~= nil then 70 | subtitles_ext = ".sup" 71 | end 72 | 73 | if track_lang ~= nil then 74 | if track_title ~= nil then 75 | subtitles_ext = "." .. track_title .. "." .. track_lang .. subtitles_ext 76 | else 77 | subtitles_ext = "." .. track_lang .. subtitles_ext 78 | end 79 | end 80 | 81 | subtitles_file = utils.join_path(dir, fname .. subtitles_ext) 82 | 83 | if o.language == 'chs' then 84 | msg.info("正在导出当前字幕") 85 | mp.osd_message("正在导出当前字幕") 86 | else 87 | msg.info("Exporting selected subtitles") 88 | mp.osd_message("Exporting selected subtitles") 89 | end 90 | 91 | cmd = string.format("%s -y -hide_banner -loglevel error -i '%s' -map '%s' -vn -an -c:s copy '%s'", 92 | o.ffmpeg_path, video_file, index, subtitles_file) 93 | windows_args = { 'powershell', '-NoProfile', '-Command', cmd } 94 | unix_args = { '/bin/bash', '-c', cmd } 95 | args = is_windows and windows_args or unix_args 96 | 97 | mp.add_timeout(mp.get_property_number("osd-duration") * 0.001, process) 98 | 99 | break 100 | end 101 | 102 | i = i + 1 103 | end 104 | end 105 | 106 | function process() 107 | local screenx, screeny, aspect = mp.get_osd_size() 108 | 109 | mp.set_osd_ass(screenx, screeny, "{\\an9}● ") 110 | local res = mp.command_native({ name = "subprocess", capture_stdout = true, playback_only = false, args = args }) 111 | mp.set_osd_ass(screenx, screeny, "") 112 | if res.status == 0 then 113 | if o.language == 'chs' then 114 | msg.info("当前字幕已导出") 115 | mp.osd_message("当前字幕已导出") 116 | else 117 | msg.info("Finished exporting subtitles") 118 | mp.osd_message("Finished exporting subtitles") 119 | end 120 | mp.commandv("sub-add", subtitles_file) 121 | mp.set_property("sub-visibility", "yes") 122 | else 123 | if o.language == 'chs' then 124 | msg.info("当前字幕导出失败") 125 | mp.osd_message("当前字幕导出失败, 查看控制台获取更多信息.") 126 | else 127 | msg.info("Failed to export subtitles") 128 | mp.osd_message("Failed to export subtitles, check console for more info.") 129 | end 130 | end 131 | end 132 | 133 | mp.register_script_message("export-selected-subtitles", export_selected_subtitles) 134 | -------------------------------------------------------------------------------- /track-list.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | * track-list.lua v.2024-11-11 3 | * 4 | * AUTHORS: dyphire 5 | * License: MIT 6 | * link: https://github.com/dyphire/mpv-scripts 7 | 8 | This script implements an interractive track list 9 | Usage: add bindings to input.conf 10 | -- key script-message-to track_list toggle-vidtrack-browser 11 | -- key script-message-to track_list toggle-audtrack-browser 12 | -- key script-message-to track_list toggle-subtrack-browser 13 | -- key script-message-to track_list toggle-secondary-subtrack-browser 14 | 15 | This script needs to be used with scroll-list.lua 16 | https://github.com/CogentRedTester/mpv-scroll-list 17 | ]] 18 | 19 | local mp = require 'mp' 20 | local opts = require("mp.options") 21 | local propNative = mp.get_property_native 22 | 23 | local o = { 24 | -- header of the list 25 | -- %cursor% and %total% to be used to display the cursor position and the total number of lists 26 | header = "Track List [%cursor%/%total%]\\N ------------------------------------", 27 | --list ass style overrides inside curly brackets 28 | --these styles will be used for the whole list. so you need to reset them for every line 29 | --read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags 30 | global_style = [[]], 31 | header_style = [[{\q2\fs30\c&00ccff&}]], 32 | list_style = [[{\q2\fs20\c&Hffffff&}]], 33 | wrapper_style = [[{\c&00ccff&\fs16}]], 34 | cursor_style = [[{\c&00ccff&}]], 35 | selected_style = [[{\c&Hfce788&}]], 36 | active_style = [[{\c&H33ff66&}]], 37 | cursor = [[➤\h]], 38 | indent = [[\h\h\h]], 39 | --amount of entries to show before slicing. Optimal value depends on font/video size etc. 40 | num_entries = 16, 41 | --slice long filenames, and how many chars to show 42 | max_title_length = 100, 43 | -- wrap the cursor around the top and bottom of the list 44 | wrap = true, 45 | -- set dynamic keybinds to bind when the list is open 46 | key_move_begin = "HOME", 47 | key_move_end = "END", 48 | key_move_pageup = "PGUP", 49 | key_move_pagedown = "PGDWN", 50 | key_scroll_down = "DOWN WHEEL_DOWN", 51 | key_scroll_up = "UP WHEEL_UP", 52 | key_select_track = "ENTER MBTN_LEFT", 53 | key_reload_track = "F5 MBTN_MID", 54 | key_remove_track = "DEL BS", 55 | key_close_browser = "ESC MBTN_RIGHT", 56 | } 57 | 58 | opts.read_options(o) 59 | 60 | --adding the source directory to the package path and loading the module 61 | package.path = mp.command_native({"expand-path", "~~/script-modules/?.lua;"}) .. package.path 62 | local list = require "scroll-list" 63 | local list_type = nil 64 | 65 | --modifying the list settings 66 | local original_open = list.open 67 | list.header = o.header 68 | list.cursor = o.cursor 69 | list.indent = o.indent 70 | list.wrap = o.wrap 71 | list.num_entries = o.num_entries 72 | list.global_style = o.global_style 73 | list.header_style = o.header_style 74 | list.list_style = o.list_style 75 | list.wrapper_style = o.wrapper_style 76 | list.cursor_style = o.cursor_style 77 | list.selected_style = o.selected_style 78 | 79 | --escape header specifies the format 80 | --display the cursor position and the total number of lists in the header 81 | function list:format_header_string(str) 82 | if #list.list > 1 then 83 | str = str:gsub("%%(%a+)%%", { cursor = list.selected - 1, total = #list.list - 1 }) 84 | else str = str:gsub("%[.*%]", "") end 85 | return str 86 | end 87 | 88 | -- from http://lua-users.org/wiki/LuaUnicode 89 | local UTF8_PATTERN = '[%z\1-\127\194-\244][\128-\191]*' 90 | 91 | -- return a substring based on utf8 characters 92 | -- like string.sub, but negative index is not supported 93 | local function utf8_sub(s, i, j) 94 | if i > j then 95 | return s 96 | end 97 | 98 | local t = {} 99 | local idx = 1 100 | for char in s:gmatch(UTF8_PATTERN) do 101 | if i <= idx and idx <= j then 102 | local width = #char > 2 and 2 or 1 103 | idx = idx + width 104 | t[#t + 1] = char 105 | end 106 | end 107 | return table.concat(t) 108 | end 109 | 110 | local function escape_codec(str) 111 | if not str or str == '' then return '' end 112 | 113 | local codec_map = { 114 | mpeg2 = "mpeg2", 115 | dvvideo = "dv", 116 | pcm = "pcm", 117 | pgs = "pgs", 118 | subrip = "srt", 119 | vtt = "vtt", 120 | dvd_sub = "vob", 121 | dvb_sub = "dvb", 122 | dvb_tele = "teletext", 123 | arib = "arib" 124 | } 125 | 126 | for key, value in pairs(codec_map) do 127 | if str:find(key) then 128 | return value 129 | end 130 | end 131 | 132 | return str 133 | end 134 | 135 | local function isTrackSelected(index, type) 136 | local selectedId = propNative("current-tracks/" .. type .. "/id") 137 | return selectedId == index 138 | end 139 | 140 | local function isTrackDisabled(index, type) 141 | return (type == "sub2" and isTrackSelected(index, "sub")) 142 | or (type == "sub" and isTrackSelected(index, "sub2")) 143 | end 144 | 145 | local function get_track_title(track, type, filename) 146 | local title = track.title or '' 147 | local codec = escape_codec(track.codec) 148 | 149 | if track.external and title ~= '' then 150 | local extension = title:match('%.([^%.]+)$') 151 | if filename ~= '' and extension then 152 | title = title:gsub(filename .. '%.?', ''):gsub('%.?([^%.]+)$', '') 153 | end 154 | if track.lang and title:lower() == track.lang:lower() then 155 | title = '' 156 | end 157 | end 158 | local title_clip = utf8_sub(title, 1, o.max_title_length) 159 | if title ~= title_clip then 160 | title = title_clip .. "..." 161 | end 162 | if title == '' then 163 | local name = type:sub(1, 1):upper() .. type:sub(2, #type) 164 | title = string.format('%s %02.f', name, track.id) 165 | end 166 | 167 | local hints = {} 168 | local function h(value) hints[#hints + 1] = value end 169 | if codec ~= '' then h(codec) end 170 | if track['demux-h'] then 171 | h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h'] or track['demux-h'] .. 'p')) 172 | end 173 | if track['demux-fps'] then h(string.format('%.5g fps', track['demux-fps'])) end 174 | if track['audio-channels'] then h(track['audio-channels'] .. ' ch') end 175 | if track['demux-samplerate'] then h(string.format('%.3g kHz', track['demux-samplerate'] / 1000)) end 176 | if track['demux-bitrate'] then h(string.format('%.0f kbps', track['demux-bitrate'] / 1000)) end 177 | if track.lang then title = string.format('%s, %s', title, track.lang) end 178 | if #hints > 0 then title = string.format('%s\t[%s]', title, table.concat(hints, ', ')) end 179 | if track.forced then title = title .. ' Forced' end 180 | if track.external then title = title .. ' External' end 181 | if track.default then title = title .. ' (Default)' end 182 | 183 | return list.ass_escape(title) 184 | end 185 | 186 | local function updateTrackList(title, type, prop) 187 | list.header = title .. ": " .. o.header 188 | 189 | local filename = propNative('filename/no-ext', ''):gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%0") 190 | local track_type = type == 'sub2' and 'sub' or type 191 | mp.observe_property("track-list", "native", function(_, track_list) 192 | mp.observe_property(prop, "native", function() 193 | list.list = {} 194 | list.list = { 195 | { 196 | id = nil, 197 | index = nil, 198 | disabled = false, 199 | ass = "○ None" 200 | } 201 | } 202 | 203 | if isTrackSelected(nil, type) then 204 | list.selected = 1 205 | list[1].ass = "● None" 206 | list[1].style = o.active_style 207 | end 208 | 209 | if not track_list then return end 210 | for _, track in ipairs(track_list) do 211 | if track.type == track_type then 212 | local title = get_track_title(track, type, filename) 213 | local isDisabled = isTrackDisabled(track.id, type) 214 | 215 | local listItem = { 216 | id = track.id, 217 | disabled = isDisabled 218 | } 219 | if isTrackSelected(track.id, type) then 220 | list.selected = track.id + 1 221 | listItem.style = o.active_style 222 | listItem.ass = "● " .. title 223 | elseif isDisabled then 224 | listItem.style = [[{\c&Hff6666&}]] 225 | listItem.ass = "○ " .. title 226 | else 227 | listItem.ass = "○ " .. title 228 | end 229 | table.insert(list.list, listItem) 230 | end 231 | end 232 | 233 | list:update() 234 | end) 235 | end) 236 | end 237 | 238 | local function selectTrack() 239 | local selected = list.list[list.selected] 240 | if selected then 241 | if selected.disabled then return end 242 | if selected.id == nil then selected.id = "no" end 243 | mp.commandv('set', list_prop, selected.id) 244 | end 245 | end 246 | 247 | local function reloadTrack() 248 | local selected = list.list[list.selected] 249 | local track_type = list_type == 'sub2' and 'sub' or list_type 250 | if selected then 251 | if selected.id == nil then return end 252 | mp.commandv(track_type .. "-reload", selected.id) 253 | end 254 | end 255 | 256 | local function removeTrack() 257 | local selected = list.list[list.selected] 258 | local track_type = list_type == 'sub2' and 'sub' or list_type 259 | if selected then 260 | if selected.id == nil then return end 261 | mp.commandv(track_type .. "-remove", selected.id) 262 | end 263 | end 264 | 265 | --dynamic keybinds to bind when the list is open 266 | list.keybinds = {} 267 | 268 | local function add_keys(keys, name, fn, flags) 269 | local i = 1 270 | for key in keys:gmatch("%S+") do 271 | table.insert(list.keybinds, { key, name .. i, fn, flags }) 272 | i = i + 1 273 | end 274 | end 275 | 276 | add_keys(o.key_scroll_down, 'scroll_down', function() list:scroll_down() end, { repeatable = true }) 277 | add_keys(o.key_scroll_up, 'scroll_up', function() list:scroll_up() end, { repeatable = true }) 278 | add_keys(o.key_move_pageup, 'move_pageup', function() list:move_pageup() end, {}) 279 | add_keys(o.key_move_pagedown, 'move_pagedown', function() list:move_pagedown() end, {}) 280 | add_keys(o.key_move_begin, 'move_begin', function() list:move_begin() end, {}) 281 | add_keys(o.key_move_end, 'move_end', function() list:move_end() end, {}) 282 | add_keys(o.key_select_track, 'select_track', selectTrack, {}) 283 | add_keys(o.key_reload_track, 'reload_track', reloadTrack, {}) 284 | add_keys(o.key_remove_track, 'remove_track', removeTrack, {}) 285 | add_keys(o.key_close_browser, 'close_browser', function() list:close() end, {}) 286 | 287 | function list:open() 288 | video_menu = (list_type == "video") 289 | audio_menu = (list_type == "audio") 290 | sub_menu = (list_type == "sub") 291 | sub2_menu = (list_type == "sub2") 292 | 293 | original_open(self) 294 | end 295 | 296 | local function toggleListDelayed() 297 | mp.add_timeout(0.1, function() 298 | list:toggle() 299 | end) 300 | end 301 | 302 | local function toggleList(type, prop) 303 | list_type = type 304 | list_prop = prop 305 | 306 | local function toggleMenu(menu) 307 | if _G[menu] then 308 | _G[menu] = false 309 | else 310 | toggleListDelayed() 311 | end 312 | end 313 | 314 | if type == "video" then 315 | toggleMenu("video_menu") 316 | elseif type == "audio" then 317 | toggleMenu("audio_menu") 318 | elseif type == "sub" then 319 | toggleMenu("sub_menu") 320 | elseif type == "sub2" then 321 | toggleMenu("sub2_menu") 322 | end 323 | end 324 | 325 | local function openTrackList(title, type, prop) 326 | list:close() 327 | updateTrackList(title, type, prop) 328 | toggleList(type, prop) 329 | end 330 | 331 | mp.register_script_message("toggle-vidtrack-browser", function() 332 | openTrackList("Video", "video", "vid") 333 | end) 334 | mp.register_script_message("toggle-audtrack-browser", function() 335 | openTrackList("Audio", "audio", "aid") 336 | end) 337 | mp.register_script_message("toggle-subtrack-browser", function() 338 | openTrackList("Subtitle", "sub", "sid") 339 | end) 340 | mp.register_script_message("toggle-secondary-subtrack-browser", function() 341 | openTrackList("Secondary Subtitle", "sub2", "secondary-sid") 342 | end) 343 | 344 | mp.register_event('end-file', function() 345 | list:close() 346 | mp.unobserve_property(updateTrackList) 347 | end) 348 | -------------------------------------------------------------------------------- /trackselect.lua: -------------------------------------------------------------------------------- 1 | -- trackselect.lua 2 | -- https://github.com/po5/trackselect 3 | -- Because --slang isn't smart enough. 4 | -- 5 | -- This script tries to select non-dub 6 | -- audio and subtitle tracks. 7 | -- Idea from https://github.com/siikamiika/scripts/blob/master/mpv%20scripts/dualaudiofix.lua 8 | 9 | local opt = require 'mp.options' 10 | local utils = require 'mp.utils' 11 | 12 | local defaults = { 13 | audio = { 14 | selected = nil, 15 | best = {}, 16 | lang_score = nil, 17 | channels_score = -math.huge, 18 | preferred = "jpn/japanese", 19 | excluded = "", 20 | expected = "", 21 | id = "" 22 | }, 23 | video = { 24 | selected = nil, 25 | best = {}, 26 | lang_score = nil, 27 | preferred = "", 28 | excluded = "", 29 | expected = "", 30 | id = "" 31 | }, 32 | sub = { 33 | selected = nil, 34 | best = {}, 35 | lang_score = nil, 36 | preferred = "eng", 37 | excluded = "sign", 38 | expected = "", 39 | id = "" 40 | } 41 | } 42 | 43 | local options = { 44 | enabled = true, 45 | 46 | -- Do track selection synchronously, plays nicer with other scripts 47 | hook = true, 48 | 49 | -- Mimic mpv's track list fingerprint to preserve user-selected tracks across files 50 | fingerprint = false, 51 | 52 | -- Override user's explicit track selection 53 | force = false, 54 | 55 | -- Try to re-select the last track if mpv cannot do it e.g. when fingerprint changes 56 | smart_keep = false, 57 | 58 | --add above (after a comma) any protocol to disable 59 | special_protocols = [[ 60 | ["://", "^magnet:"] 61 | ]], 62 | } 63 | 64 | for _type, track in pairs(defaults) do 65 | options["preferred_" .. _type .. "_lang"] = track.preferred 66 | options["excluded_" .. _type .. "_words"] = track.excluded 67 | options["expected_" .. _type .. "_words"] = track.expected 68 | end 69 | 70 | options["preferred_audio_channels"] = "" 71 | 72 | local tracks = {} 73 | local last = {} 74 | local fingerprint = "" 75 | 76 | opt.read_options(options, _, function() end) 77 | 78 | options.special_protocols = utils.parse_json(options.special_protocols) 79 | 80 | local function need_ignore(tab, val) 81 | for index, element in ipairs(tab) do 82 | if string.find(val, element) then 83 | return true 84 | end 85 | end 86 | return false 87 | end 88 | 89 | function contains(track, words, attr) 90 | if not track[attr] then return false end 91 | local i = 0 92 | if track.external then 93 | i = 1 94 | end 95 | for word in string.gmatch(words:lower(), "([^/]+)") do 96 | i = i - 1 97 | if string.find(tostring(track[attr] or ""):lower(), word) then 98 | return i 99 | end 100 | end 101 | return false 102 | end 103 | 104 | function preferred(track, words, attr, title) 105 | local score = contains(track, words, attr) 106 | if not score then 107 | if tracks[track.type][title] == nil then 108 | tracks[track.type][title] = -math.huge 109 | return true 110 | end 111 | return false 112 | end 113 | if tracks[track.type][title] == nil or score > tracks[track.type][title] then 114 | tracks[track.type][title] = score 115 | return true 116 | end 117 | return false 118 | end 119 | 120 | function preferred_or_equals(track, words, attr, title) 121 | local score = contains(track, words, attr) 122 | if not score then 123 | if tracks[track.type][title] == nil or tracks[track.type][title] == -math.huge then 124 | return true 125 | end 126 | return false 127 | end 128 | if tracks[track.type][title] == nil or score >= tracks[track.type][title] then 129 | return true 130 | end 131 | return false 132 | end 133 | 134 | function copy(obj) 135 | if type(obj) ~= "table" then return obj end 136 | local res = {} 137 | for k, v in pairs(obj) do res[k] = copy(v) end 138 | return res 139 | end 140 | 141 | function track_layout_hash(tracklist) 142 | local t = {} 143 | for _, track in ipairs(tracklist) do 144 | t[#t + 1] = string.format("%s-%d-%s-%s-%s-%s", track.type, track.id, tostring(track.default), 145 | tostring(track.external), track.lang or "", track.external and "" or (track.title or "")) 146 | end 147 | return table.concat(t, "\n") 148 | end 149 | 150 | function trackselect() 151 | local fpath = mp.get_property('path') 152 | if not options.enabled then return end 153 | if need_ignore(options.special_protocols, fpath) then return end 154 | tracks = copy(defaults) 155 | local filename = mp.get_property("filename/no-ext") 156 | local tracklist = mp.get_property_native("track-list") 157 | local tracklist_changed = false 158 | local found_last = {} 159 | if options.fingerprint then 160 | local new_fingerprint = track_layout_hash(tracklist) 161 | if new_fingerprint == fingerprint then 162 | return 163 | end 164 | fingerprint = new_fingerprint 165 | tracklist_changed = true 166 | end 167 | for _, track in ipairs(tracklist) do 168 | if options.smart_keep and last[track.type] ~= nil and last[track.type].lang == track.lang and 169 | last[track.type].codec == track.codec and last[track.type].external == track.external and 170 | last[track.type].title == track.title then 171 | tracks[track.type].best = track 172 | options["preferred_" .. track.type .. "_lang"] = "" 173 | options["excluded_" .. track.type .. "_words"] = "" 174 | options["expected_" .. track.type .. "_words"] = "" 175 | options["preferred_" .. track.type .. "_channels"] = "" 176 | found_last[track.type] = true 177 | elseif not options.force and (tracklist_changed or not options.fingerprint) then 178 | if tracks[track.type].id == "" then 179 | tracks[track.type].id = mp.get_property(track.type:sub(1, 1) .. "id", "auto") 180 | end 181 | if tracks[track.type].id ~= "auto" then 182 | options["preferred_" .. track.type .. "_lang"] = "" 183 | options["excluded_" .. track.type .. "_words"] = "" 184 | options["expected_" .. track.type .. "_words"] = "" 185 | options["preferred_" .. track.type .. "_channels"] = "" 186 | end 187 | end 188 | if options["preferred_" .. track.type .. "_lang"] ~= "" or options["excluded_" .. track.type .. "_words"] ~= "" 189 | or options["expected_" .. track.type .. "_words"] ~= "" or 190 | (options["preferred_" .. track.type .. "_channels"] or "") ~= "" then 191 | if track.selected then 192 | tracks[track.type].selected = track.id 193 | if options.smart_keep then 194 | last[track.type] = track 195 | end 196 | end 197 | if track.title then 198 | track.title = string.gsub(string.gsub(track.title, "[%(%)%.%+%-%*%?%[%]%^%$%%]", "%%%1"), filename, "") 199 | end 200 | if next(tracks[track.type].best) == nil or not (tracks[track.type].best.external 201 | and tracks[track.type].best.lang ~= nil and not track.external) then 202 | if options["excluded_" .. track.type .. "_words"] == "" or 203 | not contains(track, options["excluded_" .. track.type .. "_words"], "title") then 204 | if options["expected_" .. track.type .. "_words"] == "" or 205 | contains(track, options["expected_" .. track.type .. "_words"], "title") then 206 | local pass = true 207 | local channels = false 208 | local lang = false 209 | if (options["preferred_" .. track.type .. "_channels"] or "") ~= "" and 210 | preferred_or_equals(track, options["preferred_" .. track.type .. "_lang"], "lang", 211 | "lang_score") then 212 | channels = preferred(track, options["preferred_" .. track.type .. "_channels"], 213 | "demux-channel-count", "channels_score") 214 | pass = channels 215 | end 216 | if options["preferred_" .. track.type .. "_lang"] ~= "" then 217 | lang = preferred(track, options["preferred_" .. track.type .. "_lang"], "lang", "lang_score") 218 | end 219 | if (options["preferred_" .. track.type .. "_lang"] == "" and pass) or channels or lang or 220 | (track.external and track.lang == nil and 221 | (not tracks[track.type].best.external or tracks[track.type].best.lang == nil)) then 222 | tracks[track.type].best = track 223 | end 224 | end 225 | end 226 | end 227 | end 228 | end 229 | for _type, track in pairs(tracks) do 230 | if next(track.best) ~= nil and track.best.id ~= track.selected then 231 | mp.set_property(_type:sub(1, 1) .. "id", track.best.id) 232 | if options.smart_keep and found_last[track.best.type] then 233 | last[track.best.type] = track.best 234 | end 235 | end 236 | end 237 | end 238 | 239 | function selected_tracks() 240 | local tracklist = mp.get_property_native("track-list") 241 | last = {} 242 | for _, track in ipairs(tracklist) do 243 | if track.selected then 244 | last[track.type] = track 245 | end 246 | end 247 | end 248 | 249 | if options.hook then 250 | mp.add_hook("on_preloaded", 50, trackselect) 251 | else 252 | mp.register_event("file-loaded", trackselect) 253 | end 254 | 255 | if options.smart_keep then 256 | mp.register_event("track-switched", selected_tracks) 257 | end 258 | --------------------------------------------------------------------------------