mirror of
https://github.com/ksyasuda/dotfiles.git
synced 2026-04-10 16:19:23 -07:00
Compare commits
3 Commits
bc5ce70a7f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
0eae8ed781
|
|||
|
2e159c6ab5
|
|||
|
abb0abdb92
|
@@ -40,6 +40,7 @@
|
|||||||
"Bash(yadm push *)"
|
"Bash(yadm push *)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"model": "sonnet",
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"Notification": [
|
"Notification": [
|
||||||
{
|
{
|
||||||
@@ -61,9 +62,9 @@
|
|||||||
"code-review@claude-plugins-official": true,
|
"code-review@claude-plugins-official": true,
|
||||||
"code-simplifier@claude-plugins-official": true,
|
"code-simplifier@claude-plugins-official": true,
|
||||||
"playwright@claude-plugins-official": true,
|
"playwright@claude-plugins-official": true,
|
||||||
"superpowers@claude-plugins-official": true,
|
|
||||||
"coderabbit@claude-plugins-official": true,
|
"coderabbit@claude-plugins-official": true,
|
||||||
"discord@claude-plugins-official": true
|
"discord@claude-plugins-official": true,
|
||||||
|
"chrome-devtools-mcp@claude-plugins-official": true
|
||||||
},
|
},
|
||||||
"sandbox": {
|
"sandbox": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -79,6 +80,5 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"voiceEnabled": true,
|
"voiceEnabled": true,
|
||||||
"skipDangerousModePermissionPrompt": true,
|
"skipDangerousModePermissionPrompt": true
|
||||||
"model": "sonnet"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,58 +2,32 @@
|
|||||||
"keybindings": [
|
"keybindings": [
|
||||||
{
|
{
|
||||||
"key": "KeyF",
|
"key": "KeyF",
|
||||||
"command": [
|
"command": ["cycle", "fullscreen"],
|
||||||
"cycle",
|
|
||||||
"fullscreen"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "KeyR",
|
"key": "KeyR",
|
||||||
"command": [
|
"command": ["add", "sub-pos", -5],
|
||||||
"add",
|
|
||||||
"sub-pos",
|
|
||||||
-5
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Shift+KeyR",
|
"key": "Shift+KeyR",
|
||||||
"command": [
|
"command": ["add", "sub-pos", 5],
|
||||||
"add",
|
|
||||||
"sub-pos",
|
|
||||||
5
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "KeyJ",
|
"key": "KeyJ",
|
||||||
"command": [
|
"command": ["cycle", "sub"],
|
||||||
"cycle",
|
|
||||||
"sub"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "BracketRight",
|
"key": "BracketRight",
|
||||||
"command": [
|
"command": ["add", "sub-delay", 0.1],
|
||||||
"add",
|
|
||||||
"sub-delay",
|
|
||||||
0.1
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "BracketLeft",
|
"key": "BracketLeft",
|
||||||
"command": [
|
"command": ["add", "sub-delay", -0.1],
|
||||||
"add",
|
|
||||||
"sub-delay",
|
|
||||||
-0.1
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "Backslash",
|
"key": "Backslash",
|
||||||
"command": [
|
"command": ["set_property", "sub-delay", 0],
|
||||||
"set_property",
|
},
|
||||||
"sub-delay",
|
|
||||||
0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"copySubtitle": "CommandOrControl+C",
|
"copySubtitle": "CommandOrControl+C",
|
||||||
@@ -68,16 +42,16 @@
|
|||||||
"markAudioCard": "CommandOrControl+Shift+A",
|
"markAudioCard": "CommandOrControl+Shift+A",
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O",
|
"openRuntimeOptions": "CommandOrControl+Shift+O",
|
||||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
|
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
|
||||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I"
|
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
|
||||||
},
|
},
|
||||||
"auto_start_overlay": false,
|
"auto_start_overlay": false,
|
||||||
"texthooker": {
|
"texthooker": {
|
||||||
"launchAtStartup": true,
|
"launchAtStartup": true,
|
||||||
"openBrowser": false
|
"openBrowser": false,
|
||||||
},
|
},
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": "auto",
|
"enabled": "auto",
|
||||||
"port": 6677
|
"port": 6677,
|
||||||
},
|
},
|
||||||
"ankiConnect": {
|
"ankiConnect": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -88,19 +62,19 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8766,
|
"port": 8766,
|
||||||
"upstreamUrl": "http://127.0.0.1:8765"
|
"upstreamUrl": "http://127.0.0.1:8765",
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"audio": "ExpressionAudio",
|
"audio": "ExpressionAudio",
|
||||||
"image": "Picture",
|
"image": "Picture",
|
||||||
"sentence": "Sentence",
|
"sentence": "Sentence",
|
||||||
"miscInfo": "MiscInfo",
|
"miscInfo": "MiscInfo",
|
||||||
"translation": "SelectionText"
|
"translation": "SelectionText",
|
||||||
},
|
},
|
||||||
"ai": {
|
"ai": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"model": "openai/gpt-oss-120b:free",
|
"model": "openai/gpt-oss-120b:free",
|
||||||
"systemPrompt": "You are a translation engine for translating Japanese into natural-sounding, context-aware English. Return only the translated text with no extra explanations or commentary. The translation must preserve the original tone and intent of the source. If the input is not in the target language, translate it to the target language. If the input is already in the target language, return it as is."
|
"systemPrompt": "You are a translation engine for translating Japanese into natural-sounding, context-aware English. Return only the translated text with no extra explanations or commentary. The translation must preserve the original tone and intent of the source. If the input is not in the target language, translate it to the target language. If the input is already in the target language, return it as is.",
|
||||||
},
|
},
|
||||||
"media": {
|
"media": {
|
||||||
"generateAudio": true,
|
"generateAudio": true,
|
||||||
@@ -113,7 +87,7 @@
|
|||||||
"animatedCrf": 35,
|
"animatedCrf": 35,
|
||||||
"audioPadding": 0.5,
|
"audioPadding": 0.5,
|
||||||
"fallbackDuration": 3,
|
"fallbackDuration": 3,
|
||||||
"syncAnimatedImageToWordAudio": true
|
"syncAnimatedImageToWordAudio": true,
|
||||||
},
|
},
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": false,
|
"overwriteAudio": false,
|
||||||
@@ -122,69 +96,55 @@
|
|||||||
"highlightWord": true,
|
"highlightWord": true,
|
||||||
"notificationType": "both",
|
"notificationType": "both",
|
||||||
"showNotificationOnUpdate": true,
|
"showNotificationOnUpdate": true,
|
||||||
"autoUpdateNewCards": true
|
"autoUpdateNewCards": true,
|
||||||
},
|
},
|
||||||
"knownWords": {
|
"knownWords": {
|
||||||
"decks": {
|
"decks": {
|
||||||
"Minecraft": [
|
"Minecraft": ["Expression", "Reading"],
|
||||||
"Expression",
|
"Kaishi 1.5k": ["Word", "Word Reading"],
|
||||||
"Reading"
|
|
||||||
],
|
|
||||||
"Kaishi 1.5k": [
|
|
||||||
"Word",
|
|
||||||
"Word Reading"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"highlightEnabled": true,
|
"highlightEnabled": true,
|
||||||
"refreshMinutes": 60,
|
"refreshMinutes": 60,
|
||||||
"matchMode": "headword",
|
"matchMode": "headword",
|
||||||
"addMinedWordsImmediately": true
|
"addMinedWordsImmediately": true,
|
||||||
},
|
},
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
"minSentenceWords": 3
|
"minSentenceWords": 3,
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"pattern": "[SubMiner] %f (%t)"
|
"pattern": "[SubMiner] %f (%t)",
|
||||||
},
|
},
|
||||||
"isLapis": {
|
"isLapis": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sentenceCardModel": "Lapis Morph"
|
"sentenceCardModel": "Lapis Morph",
|
||||||
},
|
},
|
||||||
"isKiku": {
|
"isKiku": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"fieldGrouping": "manual",
|
"fieldGrouping": "manual",
|
||||||
"deleteDuplicateInAuto": false
|
"deleteDuplicateInAuto": false,
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": ["SubMiner"],
|
||||||
"SubMiner"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"ai": {
|
"ai": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"alwaysUseAiTranslation": false,
|
"alwaysUseAiTranslation": false,
|
||||||
"apiKeyCommand": "cat ~/.openrouterapikey",
|
"apiKeyCommand": "cat ~/.openrouterapikey",
|
||||||
"baseUrl": "https://openrouter.ai/api/v1",
|
"baseUrl": "https://openrouter.ai/api/v1",
|
||||||
"sourceLanguage": "Japanese"
|
"sourceLanguage": "Japanese",
|
||||||
},
|
},
|
||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
"autoLoadSecondarySub": true,
|
"autoLoadSecondarySub": true,
|
||||||
"secondarySubLanguages": [
|
"secondarySubLanguages": ["en", "eng"],
|
||||||
"en",
|
|
||||||
"eng"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"primarySubLanguages": [
|
"primarySubLanguages": ["ja", "jpn"],
|
||||||
"ja",
|
|
||||||
"jpn"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"subsync": {
|
"subsync": {
|
||||||
"defaultMode": "manual",
|
"defaultMode": "manual",
|
||||||
"alass_path": null,
|
"alass_path": null,
|
||||||
"ffsubsync_path": null,
|
"ffsubsync_path": null,
|
||||||
"ffmpeg_path": null,
|
"ffmpeg_path": null,
|
||||||
"replace": true
|
"replace": true,
|
||||||
},
|
},
|
||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP",
|
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP",
|
||||||
@@ -207,7 +167,7 @@
|
|||||||
"secondary": {
|
"secondary": {
|
||||||
"fontFamily": "Manrope, Inter",
|
"fontFamily": "Manrope, Inter",
|
||||||
"fontSize": 24,
|
"fontSize": 24,
|
||||||
"fontColor": "#cad3f5"
|
"fontColor": "#cad3f5",
|
||||||
},
|
},
|
||||||
"frequencyDictionary": {
|
"frequencyDictionary": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -216,13 +176,7 @@
|
|||||||
"mode": "single",
|
"mode": "single",
|
||||||
"matchMode": "headword",
|
"matchMode": "headword",
|
||||||
"singleColor": "#f5a97f",
|
"singleColor": "#f5a97f",
|
||||||
"bandedColors": [
|
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"],
|
||||||
"#ed8796",
|
|
||||||
"#f5a97f",
|
|
||||||
"#f9e2af",
|
|
||||||
"#a6e3a1",
|
|
||||||
"#8aadf4"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"enableJlpt": true,
|
"enableJlpt": true,
|
||||||
"jlptColors": {
|
"jlptColors": {
|
||||||
@@ -230,16 +184,16 @@
|
|||||||
"N2": "#f5a97f",
|
"N2": "#f5a97f",
|
||||||
"N3": "#f9e2af",
|
"N3": "#f9e2af",
|
||||||
"N4": "#a6e3a1",
|
"N4": "#a6e3a1",
|
||||||
"N5": "#8aadf4"
|
"N5": "#8aadf4",
|
||||||
},
|
},
|
||||||
"nPlusOneColor": "#c6a0f6",
|
"nPlusOneColor": "#c6a0f6",
|
||||||
"knownWordColor": "#a6da95"
|
"knownWordColor": "#a6da95",
|
||||||
},
|
},
|
||||||
"jimaku": {
|
"jimaku": {
|
||||||
"apiKeyCommand": "cat ~/.jimaku-api-key",
|
"apiKeyCommand": "cat ~/.jimaku-api-key",
|
||||||
"apiBaseUrl": "https://jimaku.cc",
|
"apiBaseUrl": "https://jimaku.cc",
|
||||||
"languagePreference": "ja",
|
"languagePreference": "ja",
|
||||||
"maxEntryResults": 10
|
"maxEntryResults": 10,
|
||||||
},
|
},
|
||||||
"anilist": {
|
"anilist": {
|
||||||
"characterDictionary": {
|
"characterDictionary": {
|
||||||
@@ -247,13 +201,20 @@
|
|||||||
"collapsibleSections": {
|
"collapsibleSections": {
|
||||||
"description": false,
|
"description": false,
|
||||||
"characterInformation": false,
|
"characterInformation": false,
|
||||||
"voicedBy": false
|
"voicedBy": false,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"immersionTracking": {
|
"immersionTracking": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"dbPath": ""
|
"dbPath": "",
|
||||||
|
"backend": {
|
||||||
|
"mode": "remote",
|
||||||
|
"remote": {
|
||||||
|
"baseUrl": "http://subminer-db:5432",
|
||||||
|
"deviceId": "cachypc",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"jellyfin": {
|
"jellyfin": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -268,21 +229,13 @@
|
|||||||
"remoteControlAutoConnect": true,
|
"remoteControlAutoConnect": true,
|
||||||
"autoAnnounce": false,
|
"autoAnnounce": false,
|
||||||
"remoteControlDeviceName": "SubMiner",
|
"remoteControlDeviceName": "SubMiner",
|
||||||
"directPlayContainers": [
|
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
|
||||||
"mkv",
|
|
||||||
"mp4",
|
|
||||||
"webm",
|
|
||||||
"mov",
|
|
||||||
"flac",
|
|
||||||
"mp3",
|
|
||||||
"aac"
|
|
||||||
],
|
|
||||||
"transcodeVideoCodec": "h265",
|
"transcodeVideoCodec": "h265",
|
||||||
"pullPictures": true,
|
"pullPictures": true,
|
||||||
"iconCacheDir": "~/S/japanese/subminer-jellyfin-icons"
|
"iconCacheDir": "~/S/japanese/subminer-jellyfin-icons",
|
||||||
},
|
},
|
||||||
"logging": {
|
"logging": {
|
||||||
"level": "info"
|
"level": "info",
|
||||||
},
|
},
|
||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -295,23 +248,23 @@
|
|||||||
"buttonLabel": "",
|
"buttonLabel": "",
|
||||||
"buttonUrl": "",
|
"buttonUrl": "",
|
||||||
"updateIntervalMs": 15000,
|
"updateIntervalMs": 15000,
|
||||||
"debounceMs": 750
|
"debounceMs": 750,
|
||||||
},
|
},
|
||||||
"startupWarmups": {
|
"startupWarmups": {
|
||||||
"lowPowerMode": false,
|
"lowPowerMode": false,
|
||||||
"mecab": true,
|
"mecab": true,
|
||||||
"yomitanExtension": true,
|
"yomitanExtension": true,
|
||||||
"subtitleDictionaries": true,
|
"subtitleDictionaries": true,
|
||||||
"jellyfinRemoteSession": false
|
"jellyfinRemoteSession": false,
|
||||||
},
|
},
|
||||||
"yomitan": {
|
"yomitan": {
|
||||||
"externalProfilePath": ""
|
"externalProfilePath": "",
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"toggleKey": "Backquote",
|
"toggleKey": "Backquote",
|
||||||
"serverPort": 6969,
|
"serverPort": 6969,
|
||||||
"autoStartServer": true,
|
"autoStartServer": true,
|
||||||
"autoOpenBrowser": false
|
"autoOpenBrowser": false,
|
||||||
},
|
},
|
||||||
"subtitleSidebar": {
|
"subtitleSidebar": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -327,10 +280,10 @@
|
|||||||
"timestampColor": "#a5adcb",
|
"timestampColor": "#a5adcb",
|
||||||
"activeLineColor": "#f5bde6",
|
"activeLineColor": "#f5bde6",
|
||||||
"activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)",
|
"activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)",
|
||||||
"hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)"
|
"hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)",
|
||||||
},
|
},
|
||||||
"controller": {
|
"controller": {
|
||||||
"preferredGamepadId": "8BitDo 8BitDo Ultimate 2 Wireless Controller for PC (Vendor: 2dc8 Product: 310b)",
|
"preferredGamepadId": "8BitDo 8BitDo Ultimate 2 Wireless Controller for PC (Vendor: 2dc8 Product: 310b)",
|
||||||
"preferredGamepadLabel": "8BitDo 8BitDo Ultimate 2 Wireless Controller for PC (Vendor: 2dc8 Product: 310b)"
|
"preferredGamepadLabel": "8BitDo 8BitDo Ultimate 2 Wireless Controller for PC (Vendor: 2dc8 Product: 310b)",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
max-gamma = 150
|
max-gamma = 100
|
||||||
|
|
||||||
profile {
|
profile {
|
||||||
time = 7:30
|
time = 7:30
|
||||||
@@ -8,5 +8,5 @@ profile {
|
|||||||
profile {
|
profile {
|
||||||
time = 21:00
|
time = 21:00
|
||||||
temperature = 5500
|
temperature = 5500
|
||||||
gamma = 0.8
|
gamma = 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,17 +77,17 @@ windowrule = border_size 0, match:title LunaTranslator
|
|||||||
windowrule = stay_focused on, match:class gsm_overlay
|
windowrule = stay_focused on, match:class gsm_overlay
|
||||||
# windowrule = fullscreen_state 2, match:class gsm_overlay
|
# windowrule = fullscreen_state 2, match:class gsm_overlay
|
||||||
|
|
||||||
windowrule = float on, match:class SubMiner
|
windowrule = float on, match:class subminer
|
||||||
windowrule = border_size 0, match:class SubMiner
|
windowrule = border_size 0, match:class subminer
|
||||||
windowrule = xray off override, match:class SubMiner
|
windowrule = xray off override, match:class subminer
|
||||||
windowrule = no_shadow on, match:class SubMiner
|
windowrule = no_shadow on, match:class subminer
|
||||||
windowrule = no_blur on, match:class SubMiner
|
windowrule = no_blur on, match:class subminer
|
||||||
windowrule = no_dim on, match:class SubMiner
|
windowrule = no_dim on, match:class subminer
|
||||||
windowrule = opaque on, match:class SubMiner
|
windowrule = opaque on, match:class subminer
|
||||||
windowrule = dim_around off, match:class SubMiner
|
windowrule = dim_around off, match:class subminer
|
||||||
windowrule = allows_input offf, match:class SubMiner
|
windowrule = allows_input offf, match:class subminer
|
||||||
windowrule = border_size 0, match:class steam_app_1277940
|
windowrule = border_size 0, match:class steam_app_1277940
|
||||||
windowrule = opacity 1.0 override, match:class SubMiner
|
windowrule = opacity 1.0 override, match:class subminer
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
# {{{ FEH
|
# {{{ FEH
|
||||||
|
|||||||
1
.config/mpv/scripts/modernz.lua
Symbolic link
1
.config/mpv/scripts/modernz.lua
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../mpv-modules/ModernZ/modernz.lua
|
||||||
1
.config/mpv/scripts/mpv-youtube-queue
Symbolic link
1
.config/mpv/scripts/mpv-youtube-queue
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../projects/lua/mpv-youtube-queue/mpv-youtube-queue
|
||||||
9
.config/mpv/scripts/sponsorblock_shared/CLAUDE.md
Normal file
9
.config/mpv/scripts/sponsorblock_shared/CLAUDE.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<claude-mem-context>
|
||||||
|
# Recent Activity
|
||||||
|
|
||||||
|
### Feb 5, 2026
|
||||||
|
|
||||||
|
| ID | Time | T | Title | Read |
|
||||||
|
|----|------|---|-------|------|
|
||||||
|
| #142 | 8:49 PM | 🔵 | SponsorBlock Integration Found in MPV Configuration | ~233 |
|
||||||
|
</claude-mem-context>
|
||||||
758
.config/mpv/scripts/subminer/aniskip.lua
Normal file
758
.config/mpv/scripts/subminer/aniskip.lua
Normal file
@@ -0,0 +1,758 @@
|
|||||||
|
local M = {}
|
||||||
|
local matcher = require("aniskip_match")
|
||||||
|
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local utils = ctx.utils
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local environment = ctx.environment
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
local request_generation = 0
|
||||||
|
local mal_lookup_cache = {}
|
||||||
|
local payload_cache = {}
|
||||||
|
local title_context_cache = {}
|
||||||
|
local base64_reverse = {}
|
||||||
|
local base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||||
|
|
||||||
|
for i = 1, #base64_chars do
|
||||||
|
base64_reverse[base64_chars:sub(i, i)] = i - 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local function url_encode(text)
|
||||||
|
if type(text) ~= "string" then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
local encoded = text:gsub("\n", " ")
|
||||||
|
encoded = encoded:gsub("([^%w%-_%.~ ])", function(char)
|
||||||
|
return string.format("%%%02X", string.byte(char))
|
||||||
|
end)
|
||||||
|
return encoded:gsub(" ", "%%20")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_remote_media_path()
|
||||||
|
local media_path = mp.get_property("path")
|
||||||
|
if type(media_path) ~= "string" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local trimmed = media_path:match("^%s*(.-)%s*$") or ""
|
||||||
|
if trimmed == "" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return trimmed:match("^%a[%w+.-]*://") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_json_payload(text)
|
||||||
|
if type(text) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local parsed, parse_error = utils.parse_json(text)
|
||||||
|
if type(parsed) == "table" then
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
return nil, parse_error
|
||||||
|
end
|
||||||
|
|
||||||
|
local function decode_base64(input)
|
||||||
|
if type(input) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local cleaned = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/")
|
||||||
|
cleaned = cleaned:match("^%s*(.-)%s*$") or ""
|
||||||
|
if cleaned == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if #cleaned % 4 == 1 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if #cleaned % 4 ~= 0 then
|
||||||
|
cleaned = cleaned .. string.rep("=", 4 - (#cleaned % 4))
|
||||||
|
end
|
||||||
|
if not cleaned:match("^[A-Za-z0-9+/%=]+$") then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local out = {}
|
||||||
|
local out_len = 0
|
||||||
|
for index = 1, #cleaned, 4 do
|
||||||
|
local c1 = cleaned:sub(index, index)
|
||||||
|
local c2 = cleaned:sub(index + 1, index + 1)
|
||||||
|
local c3 = cleaned:sub(index + 2, index + 2)
|
||||||
|
local c4 = cleaned:sub(index + 3, index + 3)
|
||||||
|
local v1 = base64_reverse[c1]
|
||||||
|
local v2 = base64_reverse[c2]
|
||||||
|
if not v1 or not v2 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local v3 = c3 == "=" and 0 or base64_reverse[c3]
|
||||||
|
local v4 = c4 == "=" and 0 or base64_reverse[c4]
|
||||||
|
if (c3 ~= "=" and not v3) or (c4 ~= "=" and not v4) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local n = (((v1 * 64 + v2) * 64 + v3) * 64 + v4)
|
||||||
|
local b1 = math.floor(n / 65536)
|
||||||
|
local remaining = n % 65536
|
||||||
|
local b2 = math.floor(remaining / 256)
|
||||||
|
local b3 = remaining % 256
|
||||||
|
out_len = out_len + 1
|
||||||
|
out[out_len] = string.char(b1)
|
||||||
|
if c3 ~= "=" then
|
||||||
|
out_len = out_len + 1
|
||||||
|
out[out_len] = string.char(b2)
|
||||||
|
end
|
||||||
|
if c4 ~= "=" then
|
||||||
|
out_len = out_len + 1
|
||||||
|
out[out_len] = string.char(b3)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return table.concat(out)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_launcher_payload()
|
||||||
|
local raw_payload = type(opts.aniskip_payload) == "string" and opts.aniskip_payload or ""
|
||||||
|
local trimmed = raw_payload:match("^%s*(.-)%s*$") or ""
|
||||||
|
if trimmed == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local parsed, parse_error = parse_json_payload(trimmed)
|
||||||
|
if type(parsed) == "table" then
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
|
||||||
|
local url_decoded = trimmed:gsub("%%(%x%x)", function(hex)
|
||||||
|
local value = tonumber(hex, 16)
|
||||||
|
if value then
|
||||||
|
return string.char(value)
|
||||||
|
end
|
||||||
|
return "%"
|
||||||
|
end)
|
||||||
|
if url_decoded ~= trimmed then
|
||||||
|
parsed, parse_error = parse_json_payload(url_decoded)
|
||||||
|
if type(parsed) == "table" then
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local b64_decoded = decode_base64(trimmed)
|
||||||
|
if type(b64_decoded) == "string" and b64_decoded ~= "" then
|
||||||
|
parsed, parse_error = parse_json_payload(b64_decoded)
|
||||||
|
if type(parsed) == "table" then
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
subminer_log("warn", "aniskip", "Invalid launcher AniSkip payload: " .. tostring(parse_error or "unparseable"))
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function run_json_curl_async(url, callback)
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = { "curl", "-sL", "--connect-timeout", "5", "-A", "SubMiner-mpv/ani-skip", url },
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
}, function(success, result, error)
|
||||||
|
if not success or not result or result.status ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then
|
||||||
|
local detail = error or (result and result.stderr) or "curl failed"
|
||||||
|
callback(nil, detail)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local parsed, parse_error = utils.parse_json(result.stdout)
|
||||||
|
if type(parsed) ~= "table" then
|
||||||
|
callback(nil, parse_error or "invalid json")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
callback(parsed, nil)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_episode_hint(text)
|
||||||
|
if type(text) ~= "string" or text == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local patterns = {
|
||||||
|
"[Ss]%d+[Ee](%d+)",
|
||||||
|
"[Ee][Pp]?[%s%._%-]*(%d+)",
|
||||||
|
"[%s%._%-]+(%d+)[%s%._%-]+",
|
||||||
|
}
|
||||||
|
for _, pattern in ipairs(patterns) do
|
||||||
|
local token = text:match(pattern)
|
||||||
|
if token then
|
||||||
|
local episode = tonumber(token)
|
||||||
|
if episode and episode > 0 and episode < 10000 then
|
||||||
|
return episode
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function cleanup_title(raw)
|
||||||
|
if type(raw) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local cleaned = raw
|
||||||
|
cleaned = cleaned:gsub("%b[]", " ")
|
||||||
|
cleaned = cleaned:gsub("%b()", " ")
|
||||||
|
cleaned = cleaned:gsub("[Ss]%d+[Ee]%d+", " ")
|
||||||
|
cleaned = cleaned:gsub("[Ee][Pp]?[%s%._%-]*%d+", " ")
|
||||||
|
cleaned = cleaned:gsub("[%._%-]+", " ")
|
||||||
|
cleaned = cleaned:gsub("%s+", " ")
|
||||||
|
cleaned = cleaned:match("^%s*(.-)%s*$") or ""
|
||||||
|
if cleaned == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return cleaned
|
||||||
|
end
|
||||||
|
|
||||||
|
local function extract_show_title_from_path(media_path)
|
||||||
|
if type(media_path) ~= "string" or media_path == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local normalized = media_path:gsub("\\", "/")
|
||||||
|
local segments = {}
|
||||||
|
for segment in normalized:gmatch("[^/]+") do
|
||||||
|
segments[#segments + 1] = segment
|
||||||
|
end
|
||||||
|
for index = 1, #segments do
|
||||||
|
local segment = segments[index] or ""
|
||||||
|
if segment:match("^[Ss]eason[%s%._%-]*%d+$") or segment:match("^[Ss][%s%._%-]*%d+$") then
|
||||||
|
local prior = segments[index - 1]
|
||||||
|
local cleaned = cleanup_title(prior or "")
|
||||||
|
if cleaned and cleaned ~= "" then
|
||||||
|
return cleaned
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_title_and_episode()
|
||||||
|
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
||||||
|
local forced_season = tonumber(opts.aniskip_season)
|
||||||
|
local forced_episode = tonumber(opts.aniskip_episode)
|
||||||
|
local media_title = mp.get_property("media-title")
|
||||||
|
local filename = mp.get_property("filename/no-ext") or mp.get_property("filename") or ""
|
||||||
|
local path = mp.get_property("path") or ""
|
||||||
|
local cache_key = table.concat({
|
||||||
|
tostring(forced_title or ""),
|
||||||
|
tostring(forced_season or ""),
|
||||||
|
tostring(forced_episode or ""),
|
||||||
|
tostring(media_title or ""),
|
||||||
|
tostring(filename or ""),
|
||||||
|
tostring(path or ""),
|
||||||
|
}, "\31")
|
||||||
|
local cached = title_context_cache[cache_key]
|
||||||
|
if type(cached) == "table" then
|
||||||
|
return cached.title, cached.episode, cached.season
|
||||||
|
end
|
||||||
|
local path_show_title = extract_show_title_from_path(path)
|
||||||
|
local candidate_title = nil
|
||||||
|
if path_show_title and path_show_title ~= "" then
|
||||||
|
candidate_title = path_show_title
|
||||||
|
elseif forced_title ~= "" then
|
||||||
|
candidate_title = forced_title
|
||||||
|
else
|
||||||
|
candidate_title = cleanup_title(media_title) or cleanup_title(filename) or cleanup_title(path)
|
||||||
|
end
|
||||||
|
local episode = forced_episode or parse_episode_hint(media_title) or parse_episode_hint(filename) or parse_episode_hint(path) or 1
|
||||||
|
title_context_cache[cache_key] = {
|
||||||
|
title = candidate_title,
|
||||||
|
episode = episode,
|
||||||
|
season = forced_season,
|
||||||
|
}
|
||||||
|
return candidate_title, episode, forced_season
|
||||||
|
end
|
||||||
|
|
||||||
|
local function select_best_mal_item(items, title, season)
|
||||||
|
if type(items) ~= "table" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local best_item = nil
|
||||||
|
local best_score = -math.huge
|
||||||
|
for _, item in ipairs(items) do
|
||||||
|
if type(item) == "table" and tonumber(item.id) then
|
||||||
|
local candidate_name = tostring(item.name or "")
|
||||||
|
local score = matcher.title_overlap_score(title, candidate_name) + matcher.season_signal_score(season, candidate_name)
|
||||||
|
if score > best_score then
|
||||||
|
best_score = score
|
||||||
|
best_item = item
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return best_item
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_mal_id_async(title, season, request_id, callback)
|
||||||
|
local forced_mal_id = tonumber(opts.aniskip_mal_id)
|
||||||
|
if forced_mal_id and forced_mal_id > 0 then
|
||||||
|
callback(forced_mal_id, "(forced-mal-id)")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(title) == "string" and title:match("^%d+$") then
|
||||||
|
local numeric = tonumber(title)
|
||||||
|
if numeric and numeric > 0 then
|
||||||
|
callback(numeric, title)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if type(title) ~= "string" or title == "" then
|
||||||
|
callback(nil, nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local lookup = title
|
||||||
|
if season and season > 1 then
|
||||||
|
lookup = string.format("%s Season %d", lookup, season)
|
||||||
|
end
|
||||||
|
local cache_key = string.format("%s|%s", lookup:lower(), tostring(season or "-"))
|
||||||
|
local cached = mal_lookup_cache[cache_key]
|
||||||
|
if cached ~= nil then
|
||||||
|
if cached == false then
|
||||||
|
callback(nil, lookup)
|
||||||
|
else
|
||||||
|
callback(cached, lookup)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local mal_url = "https://myanimelist.net/search/prefix.json?type=anime&keyword=" .. url_encode(lookup)
|
||||||
|
run_json_curl_async(mal_url, function(mal_json, mal_error)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not mal_json then
|
||||||
|
subminer_log("warn", "aniskip", "MAL lookup failed: " .. tostring(mal_error))
|
||||||
|
callback(nil, lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local categories = mal_json.categories
|
||||||
|
if type(categories) ~= "table" then
|
||||||
|
mal_lookup_cache[cache_key] = false
|
||||||
|
callback(nil, lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local all_items = {}
|
||||||
|
for _, category in ipairs(categories) do
|
||||||
|
if type(category) == "table" and type(category.items) == "table" then
|
||||||
|
for _, item in ipairs(category.items) do
|
||||||
|
all_items[#all_items + 1] = item
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local best_item = select_best_mal_item(all_items, title, season)
|
||||||
|
if best_item and tonumber(best_item.id) then
|
||||||
|
local matched_id = tonumber(best_item.id)
|
||||||
|
mal_lookup_cache[cache_key] = matched_id
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"aniskip",
|
||||||
|
string.format(
|
||||||
|
'MAL candidate selected (score-based): id=%s name="%s" season_hint=%s',
|
||||||
|
tostring(best_item.id),
|
||||||
|
tostring(best_item.name or ""),
|
||||||
|
tostring(season or "-")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
callback(matched_id, lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
mal_lookup_cache[cache_key] = false
|
||||||
|
callback(nil, lookup)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function set_intro_chapters(intro_start, intro_end)
|
||||||
|
if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local current = mp.get_property_native("chapter-list")
|
||||||
|
local chapters = {}
|
||||||
|
if type(current) == "table" then
|
||||||
|
for _, chapter in ipairs(current) do
|
||||||
|
local title = type(chapter) == "table" and chapter.title or nil
|
||||||
|
if type(title) ~= "string" or not title:match("^AniSkip ") then
|
||||||
|
chapters[#chapters + 1] = chapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
chapters[#chapters + 1] = { time = intro_start, title = "AniSkip Intro Start" }
|
||||||
|
chapters[#chapters + 1] = { time = intro_end, title = "AniSkip Intro End" }
|
||||||
|
table.sort(chapters, function(a, b)
|
||||||
|
local a_time = type(a) == "table" and tonumber(a.time) or 0
|
||||||
|
local b_time = type(b) == "table" and tonumber(b.time) or 0
|
||||||
|
return a_time < b_time
|
||||||
|
end)
|
||||||
|
mp.set_property_native("chapter-list", chapters)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function remove_aniskip_chapters()
|
||||||
|
local current = mp.get_property_native("chapter-list")
|
||||||
|
if type(current) ~= "table" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local chapters = {}
|
||||||
|
local changed = false
|
||||||
|
for _, chapter in ipairs(current) do
|
||||||
|
local title = type(chapter) == "table" and chapter.title or nil
|
||||||
|
if type(title) == "string" and title:match("^AniSkip ") then
|
||||||
|
changed = true
|
||||||
|
else
|
||||||
|
chapters[#chapters + 1] = chapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if changed then
|
||||||
|
mp.set_property_native("chapter-list", chapters)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function reset_aniskip_fields()
|
||||||
|
state.aniskip.prompt_shown = false
|
||||||
|
state.aniskip.found = false
|
||||||
|
state.aniskip.mal_id = nil
|
||||||
|
state.aniskip.title = nil
|
||||||
|
state.aniskip.episode = nil
|
||||||
|
state.aniskip.intro_start = nil
|
||||||
|
state.aniskip.intro_end = nil
|
||||||
|
state.aniskip.payload = nil
|
||||||
|
state.aniskip.payload_source = nil
|
||||||
|
remove_aniskip_chapters()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clear_aniskip_state()
|
||||||
|
request_generation = request_generation + 1
|
||||||
|
reset_aniskip_fields()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function skip_intro_now()
|
||||||
|
if not state.aniskip.found then
|
||||||
|
show_osd("Intro skip unavailable")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local intro_start = state.aniskip.intro_start
|
||||||
|
local intro_end = state.aniskip.intro_end
|
||||||
|
if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
|
||||||
|
show_osd("Intro markers missing")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local now = mp.get_property_number("time-pos")
|
||||||
|
if type(now) ~= "number" then
|
||||||
|
show_osd("Skip unavailable")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local epsilon = 0.35
|
||||||
|
if now < (intro_start - epsilon) or now > (intro_end + epsilon) then
|
||||||
|
show_osd("Skip intro only during intro")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
mp.set_property_number("time-pos", intro_end)
|
||||||
|
show_osd("Skipped intro")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function update_intro_button_visibility()
|
||||||
|
if not opts.aniskip_enabled or not opts.aniskip_show_button or not state.aniskip.found then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local now = mp.get_property_number("time-pos")
|
||||||
|
if type(now) ~= "number" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local in_intro = now >= (state.aniskip.intro_start or -1) and now < (state.aniskip.intro_end or -1)
|
||||||
|
local intro_start = state.aniskip.intro_start or -1
|
||||||
|
local hint_window_end = intro_start + 3
|
||||||
|
if in_intro and not state.aniskip.prompt_shown and now >= intro_start and now < hint_window_end then
|
||||||
|
local key = opts.aniskip_button_key ~= "" and opts.aniskip_button_key or DEFAULT_ANISKIP_BUTTON_KEY
|
||||||
|
local message = string.format(opts.aniskip_button_text, key)
|
||||||
|
mp.osd_message(message, tonumber(opts.aniskip_button_duration) or 3)
|
||||||
|
state.aniskip.prompt_shown = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function apply_aniskip_payload(mal_id, title, episode, payload)
|
||||||
|
local results = payload and payload.results
|
||||||
|
if type(results) ~= "table" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
for _, item in ipairs(results) do
|
||||||
|
if type(item) == "table" and item.skip_type == "op" and type(item.interval) == "table" then
|
||||||
|
local intro_start = tonumber(item.interval.start_time)
|
||||||
|
local intro_end = tonumber(item.interval.end_time)
|
||||||
|
if intro_start and intro_end and intro_end > intro_start then
|
||||||
|
state.aniskip.found = true
|
||||||
|
state.aniskip.mal_id = mal_id
|
||||||
|
state.aniskip.title = title
|
||||||
|
state.aniskip.episode = episode
|
||||||
|
state.aniskip.intro_start = intro_start
|
||||||
|
state.aniskip.intro_end = intro_end
|
||||||
|
state.aniskip.prompt_shown = false
|
||||||
|
set_intro_chapters(intro_start, intro_end)
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"aniskip",
|
||||||
|
string.format(
|
||||||
|
"Intro window %.3f -> %.3f (MAL %s, ep %s)",
|
||||||
|
intro_start,
|
||||||
|
intro_end,
|
||||||
|
tostring(mal_id or "-"),
|
||||||
|
tostring(episode or "-")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_launcher_payload()
|
||||||
|
return type(opts.aniskip_payload) == "string" and opts.aniskip_payload:match("%S") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_launcher_context()
|
||||||
|
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
||||||
|
if forced_title ~= "" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local forced_mal_id = tonumber(opts.aniskip_mal_id)
|
||||||
|
if forced_mal_id and forced_mal_id > 0 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local forced_episode = tonumber(opts.aniskip_episode)
|
||||||
|
if forced_episode and forced_episode > 0 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local forced_season = tonumber(opts.aniskip_season)
|
||||||
|
if forced_season and forced_season > 0 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if has_launcher_payload() then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function should_fetch_aniskip_async(trigger_source, callback)
|
||||||
|
if is_remote_media_path() then
|
||||||
|
callback(false, "remote-url")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if trigger_source == "script-message" or trigger_source == "overlay-start" then
|
||||||
|
callback(true, trigger_source)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if is_launcher_context() then
|
||||||
|
callback(true, "launcher-context")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(environment.is_subminer_app_running_async) == "function" then
|
||||||
|
environment.is_subminer_app_running_async(function(running)
|
||||||
|
if running then
|
||||||
|
callback(true, "subminer-app-running")
|
||||||
|
else
|
||||||
|
callback(false, "subminer-context-missing")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if environment.is_subminer_app_running() then
|
||||||
|
callback(true, "subminer-app-running")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
callback(false, "subminer-context-missing")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_lookup_titles(primary_title)
|
||||||
|
local media_title_fallback = cleanup_title(mp.get_property("media-title"))
|
||||||
|
local filename_fallback = cleanup_title(mp.get_property("filename/no-ext") or mp.get_property("filename") or "")
|
||||||
|
local path_fallback = cleanup_title(mp.get_property("path") or "")
|
||||||
|
local lookup_titles = {}
|
||||||
|
local seen_titles = {}
|
||||||
|
local function push_lookup_title(candidate)
|
||||||
|
if type(candidate) ~= "string" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local trimmed = candidate:match("^%s*(.-)%s*$") or ""
|
||||||
|
if trimmed == "" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local key = trimmed:lower()
|
||||||
|
if seen_titles[key] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
seen_titles[key] = true
|
||||||
|
lookup_titles[#lookup_titles + 1] = trimmed
|
||||||
|
end
|
||||||
|
push_lookup_title(primary_title)
|
||||||
|
push_lookup_title(media_title_fallback)
|
||||||
|
push_lookup_title(filename_fallback)
|
||||||
|
push_lookup_title(path_fallback)
|
||||||
|
return lookup_titles
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_mal_from_candidates_async(lookup_titles, season, request_id, callback, index, last_lookup)
|
||||||
|
local current_index = index or 1
|
||||||
|
local current_lookup = last_lookup
|
||||||
|
if current_index > #lookup_titles then
|
||||||
|
callback(nil, current_lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local lookup_title = lookup_titles[current_index]
|
||||||
|
subminer_log("info", "aniskip", string.format('MAL lookup attempt %d/%d using title="%s"', current_index, #lookup_titles, lookup_title))
|
||||||
|
resolve_mal_id_async(lookup_title, season, request_id, function(mal_id, lookup)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if mal_id then
|
||||||
|
callback(mal_id, lookup)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
resolve_mal_from_candidates_async(lookup_titles, season, request_id, callback, current_index + 1, lookup or current_lookup)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fetch_payload_for_episode_async(mal_id, episode, request_id, callback)
|
||||||
|
local payload_cache_key = string.format("%d:%d", mal_id, episode)
|
||||||
|
local cached_payload = payload_cache[payload_cache_key]
|
||||||
|
if cached_payload ~= nil then
|
||||||
|
if cached_payload == false then
|
||||||
|
callback(nil, nil, true)
|
||||||
|
else
|
||||||
|
callback(cached_payload, nil, true)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local url = string.format("https://api.aniskip.com/v1/skip-times/%d/%d?types=op&types=ed", mal_id, episode)
|
||||||
|
subminer_log("info", "aniskip", string.format("AniSkip URL=%s", url))
|
||||||
|
run_json_curl_async(url, function(payload, fetch_error)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not payload then
|
||||||
|
callback(nil, fetch_error, false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if payload.found ~= true then
|
||||||
|
payload_cache[payload_cache_key] = false
|
||||||
|
callback(nil, nil, false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
payload_cache[payload_cache_key] = payload
|
||||||
|
callback(payload, nil, false)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fetch_payload_from_launcher(payload, mal_id, title, episode)
|
||||||
|
if not payload then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
state.aniskip.payload = payload
|
||||||
|
state.aniskip.payload_source = "launcher"
|
||||||
|
state.aniskip.mal_id = mal_id
|
||||||
|
state.aniskip.title = title
|
||||||
|
state.aniskip.episode = episode
|
||||||
|
return apply_aniskip_payload(mal_id, title, episode, payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fetch_aniskip_for_current_media(trigger_source)
|
||||||
|
local trigger = type(trigger_source) == "string" and trigger_source or "manual"
|
||||||
|
if not opts.aniskip_enabled then
|
||||||
|
clear_aniskip_state()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
should_fetch_aniskip_async(trigger, function(allowed, reason)
|
||||||
|
if not allowed then
|
||||||
|
subminer_log("debug", "aniskip", "Skipping lookup: " .. tostring(reason))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
request_generation = request_generation + 1
|
||||||
|
local request_id = request_generation
|
||||||
|
reset_aniskip_fields()
|
||||||
|
local title, episode, season = resolve_title_and_episode()
|
||||||
|
local lookup_titles = resolve_lookup_titles(title)
|
||||||
|
local launcher_payload = resolve_launcher_payload()
|
||||||
|
if launcher_payload then
|
||||||
|
local launcher_mal_id = tonumber(opts.aniskip_mal_id)
|
||||||
|
if not launcher_mal_id then
|
||||||
|
launcher_mal_id = nil
|
||||||
|
end
|
||||||
|
if fetch_payload_from_launcher(launcher_payload, launcher_mal_id, title, episode) then
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"aniskip",
|
||||||
|
string.format(
|
||||||
|
"Using launcher-provided AniSkip payload (title=%s, season=%s, episode=%s)",
|
||||||
|
tostring(title or ""),
|
||||||
|
tostring(season or "-"),
|
||||||
|
tostring(episode or "-")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
subminer_log("info", "aniskip", "Launcher payload present but no OP interval was available")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"aniskip",
|
||||||
|
string.format(
|
||||||
|
'Query context: trigger=%s reason=%s title="%s" season=%s episode=%s (opts: title="%s" season=%s episode=%s mal_id=%s; fallback_titles=%d)',
|
||||||
|
tostring(trigger),
|
||||||
|
tostring(reason or "-"),
|
||||||
|
tostring(title or ""),
|
||||||
|
tostring(season or "-"),
|
||||||
|
tostring(episode or "-"),
|
||||||
|
tostring(opts.aniskip_title or ""),
|
||||||
|
tostring(opts.aniskip_season or "-"),
|
||||||
|
tostring(opts.aniskip_episode or "-"),
|
||||||
|
tostring(opts.aniskip_mal_id or "-"),
|
||||||
|
#lookup_titles
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
resolve_mal_from_candidates_async(lookup_titles, season, request_id, function(mal_id, mal_lookup)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not mal_id then
|
||||||
|
subminer_log("info", "aniskip", string.format('Skipped: MAL id unavailable for query="%s"', tostring(mal_lookup or "")))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
subminer_log("info", "aniskip", string.format('Resolved MAL id=%d using query="%s"', mal_id, tostring(mal_lookup or "")))
|
||||||
|
fetch_payload_for_episode_async(mal_id, episode, request_id, function(payload, fetch_error)
|
||||||
|
if request_id ~= request_generation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not payload then
|
||||||
|
if fetch_error then
|
||||||
|
subminer_log("warn", "aniskip", "AniSkip fetch failed: " .. tostring(fetch_error))
|
||||||
|
else
|
||||||
|
subminer_log("info", "aniskip", "AniSkip: no skip windows found")
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
state.aniskip.payload = payload
|
||||||
|
state.aniskip.payload_source = "remote"
|
||||||
|
if not apply_aniskip_payload(mal_id, title, episode, payload) then
|
||||||
|
subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
clear_aniskip_state = clear_aniskip_state,
|
||||||
|
skip_intro_now = skip_intro_now,
|
||||||
|
update_intro_button_visibility = update_intro_button_visibility,
|
||||||
|
fetch_aniskip_for_current_media = fetch_aniskip_for_current_media,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
150
.config/mpv/scripts/subminer/aniskip_match.lua
Normal file
150
.config/mpv/scripts/subminer/aniskip_match.lua
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
local function normalize_for_match(value)
|
||||||
|
if type(value) ~= "string" then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
return value:lower():gsub("[^%w]+", " "):gsub("%s+", " "):match("^%s*(.-)%s*$") or ""
|
||||||
|
end
|
||||||
|
|
||||||
|
local MATCH_STOPWORDS = {
|
||||||
|
the = true,
|
||||||
|
this = true,
|
||||||
|
that = true,
|
||||||
|
world = true,
|
||||||
|
animated = true,
|
||||||
|
series = true,
|
||||||
|
season = true,
|
||||||
|
no = true,
|
||||||
|
on = true,
|
||||||
|
["and"] = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function tokenize_match_words(value)
|
||||||
|
local normalized = normalize_for_match(value)
|
||||||
|
local tokens = {}
|
||||||
|
for token in normalized:gmatch("%S+") do
|
||||||
|
if #token >= 3 and not MATCH_STOPWORDS[token] then
|
||||||
|
tokens[#tokens + 1] = token
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return tokens
|
||||||
|
end
|
||||||
|
|
||||||
|
local function token_set(tokens)
|
||||||
|
local set = {}
|
||||||
|
for _, token in ipairs(tokens) do
|
||||||
|
set[token] = true
|
||||||
|
end
|
||||||
|
return set
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.title_overlap_score(expected_title, candidate_title)
|
||||||
|
local expected = normalize_for_match(expected_title)
|
||||||
|
local candidate = normalize_for_match(candidate_title)
|
||||||
|
if expected == "" or candidate == "" then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
if candidate:find(expected, 1, true) then
|
||||||
|
return 120
|
||||||
|
end
|
||||||
|
local expected_tokens = tokenize_match_words(expected_title)
|
||||||
|
local candidate_tokens = token_set(tokenize_match_words(candidate_title))
|
||||||
|
if #expected_tokens == 0 then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
local score = 0
|
||||||
|
local matched = 0
|
||||||
|
for _, token in ipairs(expected_tokens) do
|
||||||
|
if candidate_tokens[token] then
|
||||||
|
score = score + 30
|
||||||
|
matched = matched + 1
|
||||||
|
else
|
||||||
|
score = score - 20
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if matched == 0 then
|
||||||
|
score = score - 80
|
||||||
|
end
|
||||||
|
local coverage = matched / #expected_tokens
|
||||||
|
if #expected_tokens >= 2 then
|
||||||
|
if coverage >= 0.8 then
|
||||||
|
score = score + 30
|
||||||
|
elseif coverage >= 0.6 then
|
||||||
|
score = score + 10
|
||||||
|
else
|
||||||
|
score = score - 50
|
||||||
|
end
|
||||||
|
elseif coverage >= 1 then
|
||||||
|
score = score + 10
|
||||||
|
end
|
||||||
|
return score
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_any_sequel_marker(candidate_title)
|
||||||
|
local normalized = normalize_for_match(candidate_title)
|
||||||
|
if normalized == "" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local markers = {
|
||||||
|
"season 2",
|
||||||
|
"season 3",
|
||||||
|
"season 4",
|
||||||
|
"2nd season",
|
||||||
|
"3rd season",
|
||||||
|
"4th season",
|
||||||
|
"second season",
|
||||||
|
"third season",
|
||||||
|
"fourth season",
|
||||||
|
" ii ",
|
||||||
|
" iii ",
|
||||||
|
" iv ",
|
||||||
|
}
|
||||||
|
local padded = " " .. normalized .. " "
|
||||||
|
for _, marker in ipairs(markers) do
|
||||||
|
if padded:find(marker, 1, true) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.season_signal_score(requested_season, candidate_title)
|
||||||
|
local season = tonumber(requested_season)
|
||||||
|
if not season or season < 1 then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
local normalized = " " .. normalize_for_match(candidate_title) .. " "
|
||||||
|
if normalized == " " then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
if season == 1 then
|
||||||
|
return has_any_sequel_marker(candidate_title) and -60 or 20
|
||||||
|
end
|
||||||
|
|
||||||
|
local numeric_marker = string.format(" season %d ", season)
|
||||||
|
local ordinal_marker = string.format(" %dth season ", season)
|
||||||
|
local roman_markers = {
|
||||||
|
[2] = { " ii ", " second season ", " 2nd season " },
|
||||||
|
[3] = { " iii ", " third season ", " 3rd season " },
|
||||||
|
[4] = { " iv ", " fourth season ", " 4th season " },
|
||||||
|
[5] = { " v ", " fifth season ", " 5th season " },
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized:find(numeric_marker, 1, true) or normalized:find(ordinal_marker, 1, true) then
|
||||||
|
return 40
|
||||||
|
end
|
||||||
|
local aliases = roman_markers[season] or {}
|
||||||
|
for _, marker in ipairs(aliases) do
|
||||||
|
if normalized:find(marker, 1, true) then
|
||||||
|
return 40
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if has_any_sequel_marker(candidate_title) then
|
||||||
|
return -20
|
||||||
|
end
|
||||||
|
return 5
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
301
.config/mpv/scripts/subminer/binary.lua
Normal file
301
.config/mpv/scripts/subminer/binary.lua
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local utils = ctx.utils
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local environment = ctx.environment
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
|
||||||
|
local function normalize_binary_path_candidate(candidate)
|
||||||
|
if type(candidate) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local trimmed = candidate:match("^%s*(.-)%s*$") or ""
|
||||||
|
if trimmed == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if #trimmed >= 2 then
|
||||||
|
local first = trimmed:sub(1, 1)
|
||||||
|
local last = trimmed:sub(-1)
|
||||||
|
if (first == '"' and last == '"') or (first == "'" and last == "'") then
|
||||||
|
trimmed = trimmed:sub(2, -2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return trimmed ~= "" and trimmed or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function binary_candidates_from_app_path(app_path)
|
||||||
|
if environment.is_windows() then
|
||||||
|
return {
|
||||||
|
utils.join_path(app_path, "SubMiner.exe"),
|
||||||
|
utils.join_path(app_path, "subminer.exe"),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
utils.join_path(app_path, "Contents", "MacOS", "SubMiner"),
|
||||||
|
utils.join_path(app_path, "Contents", "MacOS", "subminer"),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function file_exists(path)
|
||||||
|
local info = utils.file_info(path)
|
||||||
|
if not info then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if info.is_dir ~= nil then
|
||||||
|
return not info.is_dir
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function directory_exists(path)
|
||||||
|
local info = utils.file_info(path)
|
||||||
|
return info ~= nil and info.is_dir == true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_binary_candidate(candidate)
|
||||||
|
local normalized = normalize_binary_path_candidate(candidate)
|
||||||
|
if not normalized then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if file_exists(normalized) then
|
||||||
|
return normalized
|
||||||
|
end
|
||||||
|
|
||||||
|
if environment.is_windows() then
|
||||||
|
if not normalized:lower():match("%.exe$") then
|
||||||
|
local with_exe = normalized .. ".exe"
|
||||||
|
if file_exists(with_exe) then
|
||||||
|
return with_exe
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if directory_exists(normalized) then
|
||||||
|
for _, path in ipairs(binary_candidates_from_app_path(normalized)) do
|
||||||
|
if file_exists(path) then
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if not normalized:lower():find("%.app") then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local app_root = normalized
|
||||||
|
if not app_root:lower():match("%.app$") then
|
||||||
|
app_root = normalized:match("(.+%.app)")
|
||||||
|
end
|
||||||
|
if not app_root then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, path in ipairs(binary_candidates_from_app_path(app_root)) do
|
||||||
|
if file_exists(path) then
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find_binary_override()
|
||||||
|
for _, env_name in ipairs({ "SUBMINER_APPIMAGE_PATH", "SUBMINER_BINARY_PATH" }) do
|
||||||
|
local path = resolve_binary_candidate(os.getenv(env_name))
|
||||||
|
if path and path ~= "" then
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function add_search_path(search_paths, candidate)
|
||||||
|
if type(candidate) == "string" and candidate ~= "" then
|
||||||
|
search_paths[#search_paths + 1] = candidate
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function trim_subprocess_stdout(value)
|
||||||
|
if type(value) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local trimmed = value:match("^%s*(.-)%s*$") or ""
|
||||||
|
if trimmed == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return trimmed
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find_windows_binary_via_system_lookup()
|
||||||
|
if not environment.is_windows() then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if not mp or type(mp.command_native) ~= "function" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local script = [=[
|
||||||
|
function Emit-FirstExistingPath {
|
||||||
|
param([string[]]$Candidates)
|
||||||
|
|
||||||
|
foreach ($candidate in $Candidates) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($candidate)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
|
||||||
|
Write-Output $candidate
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$runningProcess = Get-CimInstance Win32_Process |
|
||||||
|
Where-Object { $_.Name -ieq 'SubMiner.exe' -or $_.Name -ieq 'subminer.exe' } |
|
||||||
|
Select-Object -First 1 -Property ExecutablePath, CommandLine
|
||||||
|
if ($null -ne $runningProcess) {
|
||||||
|
Emit-FirstExistingPath @($runningProcess.ExecutablePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
$localAppData = [Environment]::GetFolderPath('LocalApplicationData')
|
||||||
|
$programFiles = [Environment]::GetFolderPath('ProgramFiles')
|
||||||
|
$programFilesX86 = ${env:ProgramFiles(x86)}
|
||||||
|
|
||||||
|
Emit-FirstExistingPath @(
|
||||||
|
$(if (-not [string]::IsNullOrWhiteSpace($localAppData)) { Join-Path $localAppData 'Programs\SubMiner\SubMiner.exe' } else { $null }),
|
||||||
|
$(if (-not [string]::IsNullOrWhiteSpace($programFiles)) { Join-Path $programFiles 'SubMiner\SubMiner.exe' } else { $null }),
|
||||||
|
$(if (-not [string]::IsNullOrWhiteSpace($programFilesX86)) { Join-Path $programFilesX86 'SubMiner\SubMiner.exe' } else { $null }),
|
||||||
|
'C:\SubMiner\SubMiner.exe'
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($registryPath in @(
|
||||||
|
'HKCU:\Software\Microsoft\Windows\CurrentVersion\App Paths\SubMiner.exe',
|
||||||
|
'HKLM:\Software\Microsoft\Windows\CurrentVersion\App Paths\SubMiner.exe',
|
||||||
|
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\SubMiner.exe'
|
||||||
|
)) {
|
||||||
|
try {
|
||||||
|
$appPath = (Get-ItemProperty -Path $registryPath -ErrorAction Stop).'(default)'
|
||||||
|
Emit-FirstExistingPath @($appPath)
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$commandPath = Get-Command SubMiner.exe -ErrorAction Stop | Select-Object -First 1 -ExpandProperty Source
|
||||||
|
Emit-FirstExistingPath @($commandPath)
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
]=]
|
||||||
|
|
||||||
|
local result = mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
args = {
|
||||||
|
"powershell.exe",
|
||||||
|
"-NoProfile",
|
||||||
|
"-ExecutionPolicy",
|
||||||
|
"Bypass",
|
||||||
|
"-Command",
|
||||||
|
script,
|
||||||
|
},
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = false,
|
||||||
|
})
|
||||||
|
if not result or result.status ~= 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local candidate = trim_subprocess_stdout(result.stdout)
|
||||||
|
if not candidate then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return resolve_binary_candidate(candidate)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find_binary()
|
||||||
|
local override = find_binary_override()
|
||||||
|
if override then
|
||||||
|
return override
|
||||||
|
end
|
||||||
|
|
||||||
|
local configured = resolve_binary_candidate(opts.binary_path)
|
||||||
|
if configured then
|
||||||
|
return configured
|
||||||
|
end
|
||||||
|
|
||||||
|
local system_lookup_binary = find_windows_binary_via_system_lookup()
|
||||||
|
if system_lookup_binary then
|
||||||
|
subminer_log("info", "binary", "Found Windows binary via system lookup at: " .. system_lookup_binary)
|
||||||
|
return system_lookup_binary
|
||||||
|
end
|
||||||
|
|
||||||
|
local home = os.getenv("HOME") or os.getenv("USERPROFILE") or ""
|
||||||
|
local app_data = os.getenv("APPDATA") or ""
|
||||||
|
local app_data_local = app_data ~= "" and app_data:gsub("[/\\][Rr][Oo][Aa][Mm][Ii][Nn][Gg]$", "\\Local") or ""
|
||||||
|
local local_app_data = os.getenv("LOCALAPPDATA") or utils.join_path(home, "AppData", "Local")
|
||||||
|
local program_files = os.getenv("ProgramFiles") or "C:\\Program Files"
|
||||||
|
local program_files_x86 = os.getenv("ProgramFiles(x86)") or "C:\\Program Files (x86)"
|
||||||
|
local search_paths = {}
|
||||||
|
|
||||||
|
if environment.is_windows() then
|
||||||
|
add_search_path(search_paths, utils.join_path(app_data_local, "Programs", "SubMiner", "SubMiner.exe"))
|
||||||
|
add_search_path(search_paths, utils.join_path(local_app_data, "Programs", "SubMiner", "SubMiner.exe"))
|
||||||
|
add_search_path(search_paths, utils.join_path(program_files, "SubMiner", "SubMiner.exe"))
|
||||||
|
add_search_path(search_paths, utils.join_path(program_files_x86, "SubMiner", "SubMiner.exe"))
|
||||||
|
add_search_path(search_paths, "C:\\SubMiner\\SubMiner.exe")
|
||||||
|
else
|
||||||
|
add_search_path(search_paths, "/Applications/SubMiner.app/Contents/MacOS/SubMiner")
|
||||||
|
add_search_path(search_paths, utils.join_path(home, "Applications", "SubMiner.app", "Contents", "MacOS", "SubMiner"))
|
||||||
|
add_search_path(search_paths, utils.join_path(home, ".local", "bin", "SubMiner.AppImage"))
|
||||||
|
add_search_path(search_paths, "/opt/SubMiner/SubMiner.AppImage")
|
||||||
|
add_search_path(search_paths, "/usr/local/bin/SubMiner")
|
||||||
|
add_search_path(search_paths, "/usr/local/bin/subminer")
|
||||||
|
add_search_path(search_paths, "/usr/bin/SubMiner")
|
||||||
|
add_search_path(search_paths, "/usr/bin/subminer")
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, path in ipairs(search_paths) do
|
||||||
|
if file_exists(path) then
|
||||||
|
subminer_log("info", "binary", "Found binary at: " .. path)
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensure_binary_available()
|
||||||
|
if state.binary_available and state.binary_path and file_exists(state.binary_path) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local discovered = find_binary()
|
||||||
|
if discovered then
|
||||||
|
state.binary_path = discovered
|
||||||
|
state.binary_available = true
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
state.binary_path = nil
|
||||||
|
state.binary_available = false
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalize_binary_path_candidate = normalize_binary_path_candidate,
|
||||||
|
file_exists = file_exists,
|
||||||
|
find_binary = find_binary,
|
||||||
|
ensure_binary_available = ensure_binary_available,
|
||||||
|
is_windows = environment.is_windows,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
80
.config/mpv/scripts/subminer/bootstrap.lua
Normal file
80
.config/mpv/scripts/subminer/bootstrap.lua
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
local M = {}
|
||||||
|
local BOOTSTRAP_GUARD_KEY = "__subminer_plugin_bootstrapped"
|
||||||
|
|
||||||
|
function M.init()
|
||||||
|
if rawget(_G, BOOTSTRAP_GUARD_KEY) == true then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
rawset(_G, BOOTSTRAP_GUARD_KEY, true)
|
||||||
|
|
||||||
|
local input = require("mp.input")
|
||||||
|
local mp = require("mp")
|
||||||
|
local msg = require("mp.msg")
|
||||||
|
local options_lib = require("mp.options")
|
||||||
|
local utils = require("mp.utils")
|
||||||
|
|
||||||
|
local options_helper = require("options")
|
||||||
|
local environment = require("environment").create({ mp = mp })
|
||||||
|
local opts = options_helper.load(options_lib, environment.default_socket_path())
|
||||||
|
local state = require("state").new()
|
||||||
|
|
||||||
|
local ctx = {
|
||||||
|
input = input,
|
||||||
|
mp = mp,
|
||||||
|
msg = msg,
|
||||||
|
utils = utils,
|
||||||
|
opts = opts,
|
||||||
|
state = state,
|
||||||
|
options_helper = options_helper,
|
||||||
|
environment = environment,
|
||||||
|
}
|
||||||
|
|
||||||
|
local instances = {}
|
||||||
|
|
||||||
|
local function lazy_instance(key, factory)
|
||||||
|
if instances[key] == nil then
|
||||||
|
instances[key] = factory()
|
||||||
|
end
|
||||||
|
return instances[key]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function make_lazy_proxy(key, factory)
|
||||||
|
return setmetatable({}, {
|
||||||
|
__index = function(_, member)
|
||||||
|
return lazy_instance(key, factory)[member]
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
ctx.log = make_lazy_proxy("log", function()
|
||||||
|
return require("log").create(ctx)
|
||||||
|
end)
|
||||||
|
ctx.binary = make_lazy_proxy("binary", function()
|
||||||
|
return require("binary").create(ctx)
|
||||||
|
end)
|
||||||
|
ctx.aniskip = make_lazy_proxy("aniskip", function()
|
||||||
|
return require("aniskip").create(ctx)
|
||||||
|
end)
|
||||||
|
ctx.hover = make_lazy_proxy("hover", function()
|
||||||
|
return require("hover").create(ctx)
|
||||||
|
end)
|
||||||
|
ctx.process = make_lazy_proxy("process", function()
|
||||||
|
return require("process").create(ctx)
|
||||||
|
end)
|
||||||
|
ctx.ui = make_lazy_proxy("ui", function()
|
||||||
|
return require("ui").create(ctx)
|
||||||
|
end)
|
||||||
|
ctx.messages = make_lazy_proxy("messages", function()
|
||||||
|
return require("messages").create(ctx)
|
||||||
|
end)
|
||||||
|
ctx.lifecycle = make_lazy_proxy("lifecycle", function()
|
||||||
|
return require("lifecycle").create(ctx)
|
||||||
|
end)
|
||||||
|
|
||||||
|
ctx.ui.register_keybindings()
|
||||||
|
ctx.messages.register_script_messages()
|
||||||
|
ctx.lifecycle.register_lifecycle_hooks()
|
||||||
|
ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded")
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
210
.config/mpv/scripts/subminer/environment.lua
Normal file
210
.config/mpv/scripts/subminer/environment.lua
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
|
||||||
|
local detected_backend = nil
|
||||||
|
local app_running_cache_value = nil
|
||||||
|
local app_running_cache_time = nil
|
||||||
|
local app_running_check_inflight = false
|
||||||
|
local app_running_waiters = {}
|
||||||
|
local APP_RUNNING_CACHE_TTL_SECONDS = 2
|
||||||
|
|
||||||
|
local function is_windows()
|
||||||
|
return package.config:sub(1, 1) == "\\"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_macos()
|
||||||
|
local platform = mp.get_property("platform") or ""
|
||||||
|
if platform == "macos" or platform == "darwin" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local ostype = os.getenv("OSTYPE") or ""
|
||||||
|
return ostype:find("darwin") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function default_socket_path()
|
||||||
|
if is_windows() then
|
||||||
|
return "\\\\.\\pipe\\subminer-socket"
|
||||||
|
end
|
||||||
|
return "/tmp/subminer-socket"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_linux()
|
||||||
|
return not is_windows() and not is_macos()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function now_seconds()
|
||||||
|
if type(mp.get_time) == "function" then
|
||||||
|
local value = tonumber(mp.get_time())
|
||||||
|
if value then
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return os.time()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function process_list_has_subminer(raw_process_list)
|
||||||
|
if type(raw_process_list) ~= "string" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local process_list = raw_process_list:lower()
|
||||||
|
for line in process_list:gmatch("[^\n]+") do
|
||||||
|
if is_windows() then
|
||||||
|
local image = line:match('^"([^"]+)","')
|
||||||
|
if not image then
|
||||||
|
image = line:match('^"([^"]+)"')
|
||||||
|
end
|
||||||
|
if not image then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
if image == "subminer" or image == "subminer.exe" or image == "subminer.appimage" or image == "subminer.app" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if image:find("subminer", 1, true) and not image:find(".lua", 1, true) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local argv0 = line:match('^"([^"]+)"') or line:match("^%s*([^%s]+)")
|
||||||
|
if not argv0 then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
if argv0:find("subminer.lua", 1, true) or argv0:find("subminer.conf", 1, true) then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
local exe = argv0:match("([^/\\]+)$") or argv0
|
||||||
|
if exe == "SubMiner" or exe == "SubMiner.AppImage" or exe == "SubMiner.exe" or exe == "subminer" or exe == "subminer.appimage" or exe == "subminer.exe" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if exe:find("subminer", 1, true) and exe:find("%.lua", 1, true) == nil and exe:find("%.app", 1, true) == nil then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
::continue::
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function process_scan_command()
|
||||||
|
if is_windows() then
|
||||||
|
return { "tasklist", "/FO", "CSV", "/NH" }
|
||||||
|
end
|
||||||
|
return { "ps", "-A", "-o", "args=" }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_subminer_process_running()
|
||||||
|
local result = mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
args = process_scan_command(),
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = false,
|
||||||
|
})
|
||||||
|
if not result or result.status ~= 0 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return process_list_has_subminer(result.stdout)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function flush_app_running_waiters(value)
|
||||||
|
local waiters = app_running_waiters
|
||||||
|
app_running_waiters = {}
|
||||||
|
for _, waiter in ipairs(waiters) do
|
||||||
|
waiter(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_subminer_app_running_async(callback, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local force_refresh = opts.force_refresh == true
|
||||||
|
local now = now_seconds()
|
||||||
|
if not force_refresh and app_running_cache_value ~= nil and app_running_cache_time ~= nil then
|
||||||
|
if (now - app_running_cache_time) <= APP_RUNNING_CACHE_TTL_SECONDS then
|
||||||
|
callback(app_running_cache_value)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
app_running_waiters[#app_running_waiters + 1] = callback
|
||||||
|
if app_running_check_inflight then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
app_running_check_inflight = true
|
||||||
|
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = process_scan_command(),
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = false,
|
||||||
|
}, function(success, result)
|
||||||
|
app_running_check_inflight = false
|
||||||
|
local running = false
|
||||||
|
if success and result and result.status == 0 then
|
||||||
|
running = process_list_has_subminer(result.stdout)
|
||||||
|
end
|
||||||
|
app_running_cache_value = running
|
||||||
|
app_running_cache_time = now_seconds()
|
||||||
|
flush_app_running_waiters(running)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_subminer_app_running()
|
||||||
|
local running = is_subminer_process_running()
|
||||||
|
app_running_cache_value = running
|
||||||
|
app_running_cache_time = now_seconds()
|
||||||
|
return running
|
||||||
|
end
|
||||||
|
|
||||||
|
local function set_subminer_app_running_cache(running)
|
||||||
|
app_running_cache_value = running == true
|
||||||
|
app_running_cache_time = now_seconds()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function detect_backend()
|
||||||
|
if detected_backend then
|
||||||
|
return detected_backend
|
||||||
|
end
|
||||||
|
|
||||||
|
local backend = nil
|
||||||
|
local subminer_log = ctx.log and ctx.log.subminer_log or function() end
|
||||||
|
|
||||||
|
if is_macos() then
|
||||||
|
backend = "macos"
|
||||||
|
elseif is_windows() then
|
||||||
|
backend = nil
|
||||||
|
elseif os.getenv("HYPRLAND_INSTANCE_SIGNATURE") then
|
||||||
|
backend = "hyprland"
|
||||||
|
elseif os.getenv("SWAYSOCK") then
|
||||||
|
backend = "sway"
|
||||||
|
elseif os.getenv("XDG_SESSION_TYPE") == "x11" or os.getenv("DISPLAY") then
|
||||||
|
backend = "x11"
|
||||||
|
else
|
||||||
|
subminer_log("warn", "backend", "Could not detect window manager, falling back to x11")
|
||||||
|
backend = "x11"
|
||||||
|
end
|
||||||
|
|
||||||
|
detected_backend = backend
|
||||||
|
if backend then
|
||||||
|
subminer_log("info", "backend", "Detected backend: " .. backend)
|
||||||
|
else
|
||||||
|
subminer_log("info", "backend", "No backend detected")
|
||||||
|
end
|
||||||
|
return backend
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
is_windows = is_windows,
|
||||||
|
is_macos = is_macos,
|
||||||
|
is_linux = is_linux,
|
||||||
|
default_socket_path = default_socket_path,
|
||||||
|
is_subminer_process_running = is_subminer_process_running,
|
||||||
|
is_subminer_app_running = is_subminer_app_running,
|
||||||
|
is_subminer_app_running_async = is_subminer_app_running_async,
|
||||||
|
set_subminer_app_running_cache = set_subminer_app_running_cache,
|
||||||
|
detect_backend = detect_backend,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
431
.config/mpv/scripts/subminer/hover.lua
Normal file
431
.config/mpv/scripts/subminer/hover.lua
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
local DEFAULT_HOVER_BASE_COLOR = "FFFFFF"
|
||||||
|
local DEFAULT_HOVER_COLOR = "C6A0F6"
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local msg = ctx.msg
|
||||||
|
local utils = ctx.utils
|
||||||
|
local state = ctx.state
|
||||||
|
|
||||||
|
local function to_hex_color(input)
|
||||||
|
if type(input) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local hex = input:gsub("[%#%']", ""):gsub("^0x", "")
|
||||||
|
if #hex ~= 6 and #hex ~= 3 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if #hex == 3 then
|
||||||
|
return hex:sub(1, 1) .. hex:sub(1, 1) .. hex:sub(2, 2) .. hex:sub(2, 2) .. hex:sub(3, 3) .. hex:sub(3, 3)
|
||||||
|
end
|
||||||
|
return hex
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fix_ass_color(input, fallback)
|
||||||
|
local hex = to_hex_color(input)
|
||||||
|
if not hex then
|
||||||
|
return fallback or DEFAULT_HOVER_BASE_COLOR
|
||||||
|
end
|
||||||
|
local r, g, b = hex:sub(1, 2), hex:sub(3, 4), hex:sub(5, 6)
|
||||||
|
return b .. g .. r
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sanitize_hover_ass_color(input, fallback_rgb)
|
||||||
|
local fallback = fix_ass_color(fallback_rgb or DEFAULT_HOVER_COLOR, DEFAULT_HOVER_COLOR)
|
||||||
|
local converted = fix_ass_color(input, fallback)
|
||||||
|
if converted == "000000" then
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
return converted
|
||||||
|
end
|
||||||
|
|
||||||
|
local function escape_ass_text(text)
|
||||||
|
return (text or ""):gsub("\\", "\\\\"):gsub("{", "\\{"):gsub("}", "\\}"):gsub("\n", "\\N")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_osd_dimensions()
|
||||||
|
local width = mp.get_property_number("osd-width", 0) or 0
|
||||||
|
local height = mp.get_property_number("osd-height", 0) or 0
|
||||||
|
|
||||||
|
if width <= 0 or height <= 0 then
|
||||||
|
local osd_dims = mp.get_property_native("osd-dimensions")
|
||||||
|
if type(osd_dims) == "table" and type(osd_dims.w) == "number" and osd_dims.w > 0 then
|
||||||
|
width = osd_dims.w
|
||||||
|
end
|
||||||
|
if type(osd_dims) == "table" and type(osd_dims.h) == "number" and osd_dims.h > 0 then
|
||||||
|
height = osd_dims.h
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if width <= 0 then
|
||||||
|
width = 1280
|
||||||
|
end
|
||||||
|
if height <= 0 then
|
||||||
|
height = 720
|
||||||
|
end
|
||||||
|
|
||||||
|
return width, height
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_metrics()
|
||||||
|
local sub_font_size = mp.get_property_number("sub-font-size", 36) or 36
|
||||||
|
local sub_scale = mp.get_property_number("sub-scale", 1) or 1
|
||||||
|
local sub_scale_by_window = mp.get_property_bool("sub-scale-by-window", true) == true
|
||||||
|
local sub_pos = mp.get_property_number("sub-pos", 100) or 100
|
||||||
|
local sub_margin_y = mp.get_property_number("sub-margin-y", 0) or 0
|
||||||
|
local sub_font = mp.get_property("sub-font", "sans-serif") or "sans-serif"
|
||||||
|
local sub_spacing = mp.get_property_number("sub-spacing", 0) or 0
|
||||||
|
local sub_bold = mp.get_property_bool("sub-bold", false) == true
|
||||||
|
local sub_italic = mp.get_property_bool("sub-italic", false) == true
|
||||||
|
local sub_border_size = mp.get_property_number("sub-border-size", 2) or 2
|
||||||
|
local sub_shadow_offset = mp.get_property_number("sub-shadow-offset", 0) or 0
|
||||||
|
local osd_w, osd_h = resolve_osd_dimensions()
|
||||||
|
local window_scale = 1
|
||||||
|
if sub_scale_by_window and osd_h > 0 then
|
||||||
|
window_scale = osd_h / 720
|
||||||
|
end
|
||||||
|
local effective_margin_y = sub_margin_y * window_scale
|
||||||
|
|
||||||
|
return {
|
||||||
|
font_size = sub_font_size * (sub_scale > 0 and sub_scale or 1) * window_scale,
|
||||||
|
pos = sub_pos,
|
||||||
|
margin_y = effective_margin_y,
|
||||||
|
font = sub_font,
|
||||||
|
spacing = sub_spacing,
|
||||||
|
bold = sub_bold,
|
||||||
|
italic = sub_italic,
|
||||||
|
border = sub_border_size * window_scale,
|
||||||
|
shadow = sub_shadow_offset * window_scale,
|
||||||
|
base_color = fix_ass_color(mp.get_property("sub-color"), DEFAULT_HOVER_BASE_COLOR),
|
||||||
|
hover_color = sanitize_hover_ass_color(nil, DEFAULT_HOVER_COLOR),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_subtitle_ass_property()
|
||||||
|
local ass_text = mp.get_property("sub-text/ass")
|
||||||
|
if type(ass_text) == "string" and ass_text ~= "" then
|
||||||
|
return ass_text
|
||||||
|
end
|
||||||
|
ass_text = mp.get_property("sub-text-ass")
|
||||||
|
if type(ass_text) == "string" and ass_text ~= "" then
|
||||||
|
return ass_text
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function plain_text_and_ass_map(text)
|
||||||
|
local plain = {}
|
||||||
|
local map = {}
|
||||||
|
local plain_len = 0
|
||||||
|
local i = 1
|
||||||
|
local text_len = #text
|
||||||
|
|
||||||
|
while i <= text_len do
|
||||||
|
local ch = text:sub(i, i)
|
||||||
|
if ch == "{" then
|
||||||
|
local close = text:find("}", i + 1, true)
|
||||||
|
if not close then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
i = close + 1
|
||||||
|
elseif ch == "\\" then
|
||||||
|
local esc = text:sub(i + 1, i + 1)
|
||||||
|
if esc == "N" or esc == "n" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = "\n"
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
elseif esc == "h" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = " "
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
elseif esc == "{" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = "{"
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
elseif esc == "}" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = "}"
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
elseif esc == "\\" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = "\\"
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
else
|
||||||
|
local seq_end = i + 1
|
||||||
|
while seq_end <= text_len and text:sub(seq_end, seq_end):match("[%a]") do
|
||||||
|
seq_end = seq_end + 1
|
||||||
|
end
|
||||||
|
if text:sub(seq_end, seq_end) == "(" then
|
||||||
|
local close = text:find(")", seq_end, true)
|
||||||
|
if close then
|
||||||
|
i = close + 1
|
||||||
|
else
|
||||||
|
i = seq_end + 1
|
||||||
|
end
|
||||||
|
else
|
||||||
|
i = seq_end + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = ch
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(plain), map
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find_hover_span(payload, plain)
|
||||||
|
local source_len = #plain
|
||||||
|
local cursor = 1
|
||||||
|
for _, token in ipairs(payload.tokens or {}) do
|
||||||
|
if type(token) ~= "table" or type(token.text) ~= "string" or token.text == "" then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local token_text = token.text
|
||||||
|
local start_pos = nil
|
||||||
|
local end_pos = nil
|
||||||
|
|
||||||
|
if type(token.startPos) == "number" and type(token.endPos) == "number" then
|
||||||
|
if token.startPos >= 0 and token.endPos >= token.startPos then
|
||||||
|
local candidate_start = token.startPos + 1
|
||||||
|
local candidate_stop = token.endPos
|
||||||
|
if candidate_start >= 1 and candidate_stop <= source_len and candidate_stop >= candidate_start and plain:sub(candidate_start, candidate_stop) == token_text then
|
||||||
|
start_pos = candidate_start
|
||||||
|
end_pos = candidate_stop
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not start_pos or not end_pos then
|
||||||
|
local fallback_start, fallback_stop = plain:find(token_text, cursor, true)
|
||||||
|
if not fallback_start then
|
||||||
|
fallback_start, fallback_stop = plain:find(token_text, 1, true)
|
||||||
|
end
|
||||||
|
start_pos, end_pos = fallback_start, fallback_stop
|
||||||
|
end
|
||||||
|
|
||||||
|
if start_pos and end_pos then
|
||||||
|
if token.index == payload.hoveredTokenIndex then
|
||||||
|
return start_pos, end_pos
|
||||||
|
end
|
||||||
|
cursor = end_pos + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
::continue::
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function inject_hover_color_to_ass(raw_ass, plain_map, hover_start, hover_end, hover_color, base_color)
|
||||||
|
if hover_start == nil or hover_end == nil then
|
||||||
|
return raw_ass
|
||||||
|
end
|
||||||
|
|
||||||
|
local raw_open_idx = plain_map[hover_start] or 1
|
||||||
|
local raw_close_idx = plain_map[hover_end + 1] or (#raw_ass + 1)
|
||||||
|
if raw_open_idx < 1 then
|
||||||
|
raw_open_idx = 1
|
||||||
|
end
|
||||||
|
if raw_close_idx < 1 then
|
||||||
|
raw_close_idx = 1
|
||||||
|
end
|
||||||
|
if raw_open_idx > #raw_ass + 1 then
|
||||||
|
raw_open_idx = #raw_ass + 1
|
||||||
|
end
|
||||||
|
if raw_close_idx > #raw_ass + 1 then
|
||||||
|
raw_close_idx = #raw_ass + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local before = raw_ass:sub(1, raw_open_idx - 1)
|
||||||
|
local hovered = raw_ass:sub(raw_open_idx, raw_close_idx - 1)
|
||||||
|
local after = raw_ass:sub(raw_close_idx)
|
||||||
|
local hover_suffix = string.format("\\1c&H%s&", hover_color)
|
||||||
|
|
||||||
|
-- Keep hover foreground stable even when inline ASS override tags (\1c/\c/\r) appear inside token.
|
||||||
|
hovered = hovered:gsub("{([^}]*)}", function(inner)
|
||||||
|
if inner:find("\\1c&H", 1, true) or inner:find("\\c&H", 1, true) or inner:find("\\r", 1, true) then
|
||||||
|
return "{" .. inner .. hover_suffix .. "}"
|
||||||
|
end
|
||||||
|
return "{" .. inner .. "}"
|
||||||
|
end)
|
||||||
|
|
||||||
|
local open_tag = string.format("{\\1c&H%s&}", hover_color)
|
||||||
|
local close_tag = string.format("{\\1c&H%s&}", base_color)
|
||||||
|
return before .. open_tag .. hovered .. close_tag .. after
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_hover_subtitle_content(payload)
|
||||||
|
local source_ass = get_subtitle_ass_property()
|
||||||
|
if type(source_ass) == "string" and source_ass ~= "" then
|
||||||
|
state.hover_highlight.cached_ass = source_ass
|
||||||
|
else
|
||||||
|
source_ass = state.hover_highlight.cached_ass
|
||||||
|
end
|
||||||
|
if type(source_ass) ~= "string" or source_ass == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local plain_source, plain_map = plain_text_and_ass_map(source_ass)
|
||||||
|
if type(plain_source) ~= "string" or plain_source == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local hover_start, hover_end = find_hover_span(payload, plain_source)
|
||||||
|
if not hover_start or not hover_end then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local metrics = resolve_metrics()
|
||||||
|
local hover_color = sanitize_hover_ass_color(payload.colors and payload.colors.hover or nil, DEFAULT_HOVER_COLOR)
|
||||||
|
local base_color = fix_ass_color(payload.colors and payload.colors.base or nil, metrics.base_color)
|
||||||
|
return inject_hover_color_to_ass(source_ass, plain_map, hover_start, hover_end, hover_color, base_color)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clear_hover_overlay()
|
||||||
|
if state.hover_highlight.clear_timer then
|
||||||
|
state.hover_highlight.clear_timer:kill()
|
||||||
|
state.hover_highlight.clear_timer = nil
|
||||||
|
end
|
||||||
|
if state.hover_highlight.overlay_active then
|
||||||
|
if type(state.hover_highlight.saved_sub_visibility) == "string" then
|
||||||
|
mp.set_property("sub-visibility", state.hover_highlight.saved_sub_visibility)
|
||||||
|
else
|
||||||
|
mp.set_property("sub-visibility", "yes")
|
||||||
|
end
|
||||||
|
if type(state.hover_highlight.saved_secondary_sub_visibility) == "string" then
|
||||||
|
mp.set_property("secondary-sub-visibility", state.hover_highlight.saved_secondary_sub_visibility)
|
||||||
|
end
|
||||||
|
state.hover_highlight.saved_sub_visibility = nil
|
||||||
|
state.hover_highlight.saved_secondary_sub_visibility = nil
|
||||||
|
state.hover_highlight.overlay_active = false
|
||||||
|
end
|
||||||
|
mp.set_osd_ass(0, 0, "")
|
||||||
|
state.hover_highlight.payload = nil
|
||||||
|
state.hover_highlight.revision = -1
|
||||||
|
state.hover_highlight.cached_ass = nil
|
||||||
|
state.hover_highlight.last_hover_update_ts = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local function schedule_hover_clear(delay_seconds)
|
||||||
|
if state.hover_highlight.clear_timer then
|
||||||
|
state.hover_highlight.clear_timer:kill()
|
||||||
|
state.hover_highlight.clear_timer = nil
|
||||||
|
end
|
||||||
|
state.hover_highlight.clear_timer = mp.add_timeout(delay_seconds or 0.08, function()
|
||||||
|
state.hover_highlight.clear_timer = nil
|
||||||
|
clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function render_hover_overlay(payload)
|
||||||
|
if not payload or payload.hoveredTokenIndex == nil or payload.subtitle == nil then
|
||||||
|
clear_hover_overlay()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local ass = build_hover_subtitle_content(payload)
|
||||||
|
if not ass then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local osd_w, osd_h = resolve_osd_dimensions()
|
||||||
|
local metrics = resolve_metrics()
|
||||||
|
local osd_dims = mp.get_property_native("osd-dimensions")
|
||||||
|
local ml = (type(osd_dims) == "table" and type(osd_dims.ml) == "number") and osd_dims.ml or 0
|
||||||
|
local mr = (type(osd_dims) == "table" and type(osd_dims.mr) == "number") and osd_dims.mr or 0
|
||||||
|
local mt = (type(osd_dims) == "table" and type(osd_dims.mt) == "number") and osd_dims.mt or 0
|
||||||
|
local mb = (type(osd_dims) == "table" and type(osd_dims.mb) == "number") and osd_dims.mb or 0
|
||||||
|
local usable_w = math.max(1, osd_w - ml - mr)
|
||||||
|
local usable_h = math.max(1, osd_h - mt - mb)
|
||||||
|
local anchor_x = math.floor(ml + usable_w / 2)
|
||||||
|
local baseline_adjust = (metrics.border + metrics.shadow) * 5
|
||||||
|
local anchor_y = math.floor(mt + (usable_h * metrics.pos / 100) - metrics.margin_y + baseline_adjust)
|
||||||
|
local font_size = math.max(8, metrics.font_size)
|
||||||
|
local anchor_tag = string.format(
|
||||||
|
"{\\an2\\q2\\pos(%d,%d)\\fn%s\\fs%g\\b%d\\i%d\\fsp%g\\bord%g\\shad%g\\1c&H%s&}",
|
||||||
|
anchor_x,
|
||||||
|
anchor_y,
|
||||||
|
escape_ass_text(metrics.font),
|
||||||
|
font_size,
|
||||||
|
metrics.bold and 1 or 0,
|
||||||
|
metrics.italic and 1 or 0,
|
||||||
|
metrics.spacing,
|
||||||
|
metrics.border,
|
||||||
|
metrics.shadow,
|
||||||
|
metrics.base_color
|
||||||
|
)
|
||||||
|
if not state.hover_highlight.overlay_active then
|
||||||
|
state.hover_highlight.saved_sub_visibility = mp.get_property("sub-visibility")
|
||||||
|
state.hover_highlight.saved_secondary_sub_visibility = mp.get_property("secondary-sub-visibility")
|
||||||
|
mp.set_property("sub-visibility", "no")
|
||||||
|
mp.set_property("secondary-sub-visibility", "no")
|
||||||
|
state.hover_highlight.overlay_active = true
|
||||||
|
end
|
||||||
|
mp.set_osd_ass(osd_w, osd_h, anchor_tag .. ass)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function handle_hover_message(payload_json)
|
||||||
|
local parsed, parse_error = utils.parse_json(payload_json)
|
||||||
|
if not parsed then
|
||||||
|
msg.warn("Invalid hover-highlight payload: " .. tostring(parse_error))
|
||||||
|
clear_hover_overlay()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(parsed.revision) ~= "number" then
|
||||||
|
clear_hover_overlay()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if parsed.revision < state.hover_highlight.revision then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(parsed.hoveredTokenIndex) == "number" and type(parsed.tokens) == "table" then
|
||||||
|
if state.hover_highlight.clear_timer then
|
||||||
|
state.hover_highlight.clear_timer:kill()
|
||||||
|
state.hover_highlight.clear_timer = nil
|
||||||
|
end
|
||||||
|
state.hover_highlight.revision = parsed.revision
|
||||||
|
state.hover_highlight.payload = parsed
|
||||||
|
state.hover_highlight.last_hover_update_ts = mp.get_time() or 0
|
||||||
|
render_hover_overlay(state.hover_highlight.payload)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local now = mp.get_time() or 0
|
||||||
|
local elapsed_since_hover = now - (state.hover_highlight.last_hover_update_ts or 0)
|
||||||
|
state.hover_highlight.revision = parsed.revision
|
||||||
|
state.hover_highlight.payload = nil
|
||||||
|
if state.hover_highlight.overlay_active then
|
||||||
|
if elapsed_since_hover > 0.35 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
schedule_hover_clear(0.08)
|
||||||
|
else
|
||||||
|
clear_hover_overlay()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
HOVER_MESSAGE_NAME = "subminer-hover-token",
|
||||||
|
HOVER_MESSAGE_NAME_LEGACY = "yomipv-hover-token",
|
||||||
|
handle_hover_message = handle_hover_message,
|
||||||
|
clear_hover_overlay = clear_hover_overlay,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
7
.config/mpv/scripts/subminer/init.lua
Normal file
7
.config/mpv/scripts/subminer/init.lua
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.init()
|
||||||
|
require("bootstrap").init()
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
111
.config/mpv/scripts/subminer/lifecycle.lua
Normal file
111
.config/mpv/scripts/subminer/lifecycle.lua
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local options_helper = ctx.options_helper
|
||||||
|
local process = ctx.process
|
||||||
|
local aniskip = ctx.aniskip
|
||||||
|
local hover = ctx.hover
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
|
||||||
|
local function schedule_aniskip_fetch(trigger_source, delay_seconds)
|
||||||
|
local delay = tonumber(delay_seconds) or 0
|
||||||
|
mp.add_timeout(delay, function()
|
||||||
|
aniskip.fetch_aniskip_for_current_media(trigger_source)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_auto_start_enabled()
|
||||||
|
local raw_auto_start = opts.auto_start
|
||||||
|
if raw_auto_start == nil then
|
||||||
|
raw_auto_start = opts.auto_start_overlay
|
||||||
|
end
|
||||||
|
if raw_auto_start == nil then
|
||||||
|
raw_auto_start = opts["auto-start"]
|
||||||
|
end
|
||||||
|
return options_helper.coerce_bool(raw_auto_start, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function on_file_loaded()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
process.disarm_auto_play_ready_gate()
|
||||||
|
|
||||||
|
local should_auto_start = resolve_auto_start_enabled()
|
||||||
|
if should_auto_start then
|
||||||
|
if not process.has_matching_mpv_ipc_socket(opts.socket_path) then
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"lifecycle",
|
||||||
|
"Skipping auto-start: input-ipc-server does not match configured socket_path"
|
||||||
|
)
|
||||||
|
schedule_aniskip_fetch("file-loaded", 0)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
process.start_overlay({
|
||||||
|
auto_start_trigger = true,
|
||||||
|
socket_path = opts.socket_path,
|
||||||
|
})
|
||||||
|
-- Give the overlay process a moment to initialize before querying AniSkip.
|
||||||
|
schedule_aniskip_fetch("overlay-start", 0.8)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
schedule_aniskip_fetch("file-loaded", 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function on_shutdown()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
process.disarm_auto_play_ready_gate()
|
||||||
|
if state.overlay_running then
|
||||||
|
subminer_log("info", "lifecycle", "mpv shutting down, hiding SubMiner overlay")
|
||||||
|
process.hide_visible_overlay()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function register_lifecycle_hooks()
|
||||||
|
mp.register_event("file-loaded", on_file_loaded)
|
||||||
|
mp.register_event("shutdown", on_shutdown)
|
||||||
|
mp.register_event("file-loaded", function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_event("end-file", function()
|
||||||
|
process.disarm_auto_play_ready_gate()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
if state.overlay_running then
|
||||||
|
process.hide_visible_overlay()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
mp.register_event("shutdown", function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_event("end-file", function()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
end)
|
||||||
|
mp.register_event("shutdown", function()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
end)
|
||||||
|
mp.add_hook("on_unload", 10, function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
end)
|
||||||
|
mp.observe_property("sub-start", "native", function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
mp.observe_property("time-pos", "number", function()
|
||||||
|
aniskip.update_intro_button_visibility()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
on_file_loaded = on_file_loaded,
|
||||||
|
on_shutdown = on_shutdown,
|
||||||
|
register_lifecycle_hooks = register_lifecycle_hooks,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
67
.config/mpv/scripts/subminer/log.lua
Normal file
67
.config/mpv/scripts/subminer/log.lua
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
local LOG_LEVEL_PRIORITY = {
|
||||||
|
debug = 10,
|
||||||
|
info = 20,
|
||||||
|
warn = 30,
|
||||||
|
error = 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local msg = ctx.msg
|
||||||
|
local opts = ctx.opts
|
||||||
|
|
||||||
|
local function normalize_log_level(level)
|
||||||
|
local normalized = (level or "info"):lower()
|
||||||
|
if LOG_LEVEL_PRIORITY[normalized] then
|
||||||
|
return normalized
|
||||||
|
end
|
||||||
|
return "info"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function should_log(level)
|
||||||
|
local current = normalize_log_level(opts.log_level)
|
||||||
|
local target = normalize_log_level(level)
|
||||||
|
return LOG_LEVEL_PRIORITY[target] >= LOG_LEVEL_PRIORITY[current]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function subminer_log(level, scope, message)
|
||||||
|
if not should_log(level) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local timestamp = os.date("%Y-%m-%d %H:%M:%S")
|
||||||
|
local line = string.format("[subminer] - %s - %s - [%s] %s", timestamp, string.upper(level), scope, message)
|
||||||
|
if level == "error" then
|
||||||
|
msg.error(line)
|
||||||
|
elseif level == "warn" then
|
||||||
|
msg.warn(line)
|
||||||
|
elseif level == "debug" then
|
||||||
|
msg.debug(line)
|
||||||
|
else
|
||||||
|
msg.info(line)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function show_osd(message)
|
||||||
|
if opts.osd_messages then
|
||||||
|
local payload = "SubMiner: " .. message
|
||||||
|
local sent = false
|
||||||
|
if type(mp.osd_message) == "function" then
|
||||||
|
sent = pcall(mp.osd_message, payload, 3)
|
||||||
|
end
|
||||||
|
if not sent and type(mp.commandv) == "function" then
|
||||||
|
pcall(mp.commandv, "show-text", payload, "3000")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalize_log_level = normalize_log_level,
|
||||||
|
should_log = should_log,
|
||||||
|
subminer_log = subminer_log,
|
||||||
|
show_osd = show_osd,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
30
.config/mpv/scripts/subminer/main.lua
Normal file
30
.config/mpv/scripts/subminer/main.lua
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
local mp = require("mp")
|
||||||
|
|
||||||
|
local function current_script_dir()
|
||||||
|
if type(mp.get_script_directory) == "function" then
|
||||||
|
local from_mpv = mp.get_script_directory()
|
||||||
|
if type(from_mpv) == "string" and from_mpv ~= "" then
|
||||||
|
return from_mpv
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local source = debug.getinfo(1, "S").source or ""
|
||||||
|
if source:sub(1, 1) == "@" then
|
||||||
|
local full = source:sub(2)
|
||||||
|
return full:match("^(.*)[/\\][^/\\]+$") or "."
|
||||||
|
end
|
||||||
|
return "."
|
||||||
|
end
|
||||||
|
|
||||||
|
local script_dir = current_script_dir()
|
||||||
|
local module_patterns = script_dir .. "/?.lua;" .. script_dir .. "/?/init.lua;"
|
||||||
|
if not package.path:find(module_patterns, 1, true) then
|
||||||
|
package.path = module_patterns .. package.path
|
||||||
|
end
|
||||||
|
|
||||||
|
local init_module = assert(loadfile(script_dir .. "/init.lua"))()
|
||||||
|
if type(init_module) == "table" and type(init_module.init) == "function" then
|
||||||
|
init_module.init()
|
||||||
|
elseif type(init_module) == "function" then
|
||||||
|
init_module()
|
||||||
|
end
|
||||||
57
.config/mpv/scripts/subminer/messages.lua
Normal file
57
.config/mpv/scripts/subminer/messages.lua
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local process = ctx.process
|
||||||
|
local aniskip = ctx.aniskip
|
||||||
|
local hover = ctx.hover
|
||||||
|
local ui = ctx.ui
|
||||||
|
|
||||||
|
local function register_script_messages()
|
||||||
|
mp.register_script_message("subminer-start", function(...)
|
||||||
|
process.start_overlay_from_script_message(...)
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-stop", function()
|
||||||
|
process.stop_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-toggle", function()
|
||||||
|
process.toggle_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-menu", function()
|
||||||
|
ui.show_menu()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-options", function()
|
||||||
|
process.open_options()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-restart", function()
|
||||||
|
process.restart_overlay()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-status", function()
|
||||||
|
process.check_status()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-autoplay-ready", function()
|
||||||
|
process.notify_auto_play_ready()
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-aniskip-refresh", function()
|
||||||
|
aniskip.fetch_aniskip_for_current_media("script-message")
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-skip-intro", function()
|
||||||
|
aniskip.skip_intro_now()
|
||||||
|
end)
|
||||||
|
mp.register_script_message(hover.HOVER_MESSAGE_NAME, function(payload_json)
|
||||||
|
hover.handle_hover_message(payload_json)
|
||||||
|
end)
|
||||||
|
mp.register_script_message(hover.HOVER_MESSAGE_NAME_LEGACY, function(payload_json)
|
||||||
|
hover.handle_hover_message(payload_json)
|
||||||
|
end)
|
||||||
|
mp.register_script_message("subminer-stats-toggle", function()
|
||||||
|
mp.osd_message("Stats: press ` (backtick) in overlay", 3)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
register_script_messages = register_script_messages,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
72
.config/mpv/scripts/subminer/options.lua
Normal file
72
.config/mpv/scripts/subminer/options.lua
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
local M = {}
|
||||||
|
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
|
||||||
|
|
||||||
|
local function normalize_socket_path_option(socket_path, default_socket_path)
|
||||||
|
if type(default_socket_path) ~= "string" then
|
||||||
|
return socket_path
|
||||||
|
end
|
||||||
|
|
||||||
|
local trimmed_default = default_socket_path:match("^%s*(.-)%s*$")
|
||||||
|
local trimmed_socket = type(socket_path) == "string" and socket_path:match("^%s*(.-)%s*$") or socket_path
|
||||||
|
if trimmed_default ~= "\\\\.\\pipe\\subminer-socket" then
|
||||||
|
return trimmed_socket
|
||||||
|
end
|
||||||
|
if type(trimmed_socket) ~= "string" or trimmed_socket == "" then
|
||||||
|
return trimmed_default
|
||||||
|
end
|
||||||
|
if trimmed_socket == "/tmp/subminer-socket" or trimmed_socket == "\\tmp\\subminer-socket" then
|
||||||
|
return trimmed_default
|
||||||
|
end
|
||||||
|
if trimmed_socket == "\\\\.\\pipe\\tmp\\subminer-socket" then
|
||||||
|
return trimmed_default
|
||||||
|
end
|
||||||
|
return trimmed_socket
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.load(options_lib, default_socket_path)
|
||||||
|
local opts = {
|
||||||
|
binary_path = "",
|
||||||
|
socket_path = default_socket_path,
|
||||||
|
texthooker_enabled = true,
|
||||||
|
texthooker_port = 5174,
|
||||||
|
backend = "auto",
|
||||||
|
auto_start = true,
|
||||||
|
auto_start_visible_overlay = true,
|
||||||
|
auto_start_pause_until_ready = true,
|
||||||
|
auto_start_pause_until_ready_timeout_seconds = 15,
|
||||||
|
osd_messages = true,
|
||||||
|
log_level = "info",
|
||||||
|
aniskip_enabled = true,
|
||||||
|
aniskip_title = "",
|
||||||
|
aniskip_season = "",
|
||||||
|
aniskip_mal_id = "",
|
||||||
|
aniskip_episode = "",
|
||||||
|
aniskip_payload = "",
|
||||||
|
aniskip_show_button = true,
|
||||||
|
aniskip_button_text = "You can skip by pressing %s",
|
||||||
|
aniskip_button_key = DEFAULT_ANISKIP_BUTTON_KEY,
|
||||||
|
aniskip_button_duration = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
options_lib.read_options(opts, "subminer")
|
||||||
|
opts.socket_path = normalize_socket_path_option(opts.socket_path, default_socket_path)
|
||||||
|
return opts
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.coerce_bool(value, fallback)
|
||||||
|
if type(value) == "boolean" then
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
if type(value) == "string" then
|
||||||
|
local normalized = value:lower()
|
||||||
|
if normalized == "yes" or normalized == "true" or normalized == "1" or normalized == "on" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if normalized == "no" or normalized == "false" or normalized == "0" or normalized == "off" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
542
.config/mpv/scripts/subminer/process.lua
Normal file
542
.config/mpv/scripts/subminer/process.lua
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
|
||||||
|
local OVERLAY_START_MAX_ATTEMPTS = 6
|
||||||
|
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
|
||||||
|
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
||||||
|
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local binary = ctx.binary
|
||||||
|
local environment = ctx.environment
|
||||||
|
local options_helper = ctx.options_helper
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
local normalize_log_level = ctx.log.normalize_log_level
|
||||||
|
local run_control_command_async
|
||||||
|
|
||||||
|
local function resolve_visible_overlay_startup()
|
||||||
|
local raw_visible_overlay = opts.auto_start_visible_overlay
|
||||||
|
if raw_visible_overlay == nil then
|
||||||
|
raw_visible_overlay = opts["auto-start-visible-overlay"]
|
||||||
|
end
|
||||||
|
return options_helper.coerce_bool(raw_visible_overlay, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_pause_until_ready()
|
||||||
|
local raw_pause_until_ready = opts.auto_start_pause_until_ready
|
||||||
|
if raw_pause_until_ready == nil then
|
||||||
|
raw_pause_until_ready = opts["auto-start-pause-until-ready"]
|
||||||
|
end
|
||||||
|
return options_helper.coerce_bool(raw_pause_until_ready, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_pause_until_ready_timeout_seconds()
|
||||||
|
local raw_timeout_seconds = opts.auto_start_pause_until_ready_timeout_seconds
|
||||||
|
if raw_timeout_seconds == nil then
|
||||||
|
raw_timeout_seconds = opts["auto-start-pause-until-ready-timeout-seconds"]
|
||||||
|
end
|
||||||
|
if type(raw_timeout_seconds) == "number" then
|
||||||
|
return raw_timeout_seconds
|
||||||
|
end
|
||||||
|
if type(raw_timeout_seconds) == "string" then
|
||||||
|
local parsed = tonumber(raw_timeout_seconds)
|
||||||
|
if parsed ~= nil then
|
||||||
|
return parsed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS
|
||||||
|
end
|
||||||
|
|
||||||
|
local function normalize_socket_path(path)
|
||||||
|
if type(path) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local trimmed = path:match("^%s*(.-)%s*$")
|
||||||
|
if trimmed == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return trimmed
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_matching_mpv_ipc_socket(target_socket_path)
|
||||||
|
local expected_socket = normalize_socket_path(target_socket_path or opts.socket_path)
|
||||||
|
local active_socket = normalize_socket_path(mp.get_property("input-ipc-server"))
|
||||||
|
if expected_socket == nil or active_socket == nil then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return expected_socket == active_socket
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_backend(override_backend)
|
||||||
|
local selected = override_backend
|
||||||
|
if selected == nil or selected == "" then
|
||||||
|
selected = opts.backend
|
||||||
|
end
|
||||||
|
if selected == "auto" then
|
||||||
|
return environment.detect_backend()
|
||||||
|
end
|
||||||
|
return selected
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clear_auto_play_ready_timeout()
|
||||||
|
local timeout = state.auto_play_ready_timeout
|
||||||
|
if timeout and timeout.kill then
|
||||||
|
timeout:kill()
|
||||||
|
end
|
||||||
|
state.auto_play_ready_timeout = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clear_auto_play_ready_osd_timer()
|
||||||
|
local timer = state.auto_play_ready_osd_timer
|
||||||
|
if timer and timer.kill then
|
||||||
|
timer:kill()
|
||||||
|
end
|
||||||
|
state.auto_play_ready_osd_timer = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function disarm_auto_play_ready_gate(options)
|
||||||
|
local should_resume = options == nil or options.resume_playback ~= false
|
||||||
|
local was_armed = state.auto_play_ready_gate_armed
|
||||||
|
clear_auto_play_ready_timeout()
|
||||||
|
clear_auto_play_ready_osd_timer()
|
||||||
|
state.auto_play_ready_gate_armed = false
|
||||||
|
if was_armed and should_resume then
|
||||||
|
mp.set_property_native("pause", false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function release_auto_play_ready_gate(reason)
|
||||||
|
if not state.auto_play_ready_gate_armed then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
disarm_auto_play_ready_gate({ resume_playback = false })
|
||||||
|
mp.set_property_native("pause", false)
|
||||||
|
show_osd(AUTO_PLAY_READY_READY_OSD)
|
||||||
|
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function arm_auto_play_ready_gate()
|
||||||
|
if state.auto_play_ready_gate_armed then
|
||||||
|
clear_auto_play_ready_timeout()
|
||||||
|
clear_auto_play_ready_osd_timer()
|
||||||
|
end
|
||||||
|
state.auto_play_ready_gate_armed = true
|
||||||
|
mp.set_property_native("pause", true)
|
||||||
|
show_osd(AUTO_PLAY_READY_LOADING_OSD)
|
||||||
|
if type(mp.add_periodic_timer) == "function" then
|
||||||
|
state.auto_play_ready_osd_timer = mp.add_periodic_timer(2.5, function()
|
||||||
|
if state.auto_play_ready_gate_armed then
|
||||||
|
show_osd(AUTO_PLAY_READY_LOADING_OSD)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
subminer_log("info", "process", "Pausing playback until SubMiner overlay/tokenization readiness signal")
|
||||||
|
local timeout_seconds = resolve_pause_until_ready_timeout_seconds()
|
||||||
|
if timeout_seconds and timeout_seconds > 0 then
|
||||||
|
state.auto_play_ready_timeout = mp.add_timeout(timeout_seconds, function()
|
||||||
|
if not state.auto_play_ready_gate_armed then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
subminer_log(
|
||||||
|
"warn",
|
||||||
|
"process",
|
||||||
|
"Startup readiness signal timed out; resuming playback to avoid stalled pause"
|
||||||
|
)
|
||||||
|
release_auto_play_ready_gate("timeout")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function notify_auto_play_ready()
|
||||||
|
release_auto_play_ready_gate("tokenization-ready")
|
||||||
|
if state.suppress_ready_overlay_restore then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if state.overlay_running and resolve_visible_overlay_startup() then
|
||||||
|
run_control_command_async("show-visible-overlay", {
|
||||||
|
socket_path = opts.socket_path,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_command_args(action, overrides)
|
||||||
|
overrides = overrides or {}
|
||||||
|
local args = { state.binary_path }
|
||||||
|
|
||||||
|
table.insert(args, "--" .. action)
|
||||||
|
local log_level = normalize_log_level(overrides.log_level or opts.log_level)
|
||||||
|
if log_level ~= "info" then
|
||||||
|
table.insert(args, "--log-level")
|
||||||
|
table.insert(args, log_level)
|
||||||
|
end
|
||||||
|
|
||||||
|
if action == "start" then
|
||||||
|
local backend = resolve_backend(overrides.backend)
|
||||||
|
if backend and backend ~= "" then
|
||||||
|
table.insert(args, "--backend")
|
||||||
|
table.insert(args, backend)
|
||||||
|
end
|
||||||
|
|
||||||
|
local socket_path = overrides.socket_path or opts.socket_path
|
||||||
|
table.insert(args, "--socket")
|
||||||
|
table.insert(args, socket_path)
|
||||||
|
|
||||||
|
local should_show_visible = resolve_visible_overlay_startup()
|
||||||
|
if should_show_visible then
|
||||||
|
table.insert(args, "--show-visible-overlay")
|
||||||
|
else
|
||||||
|
table.insert(args, "--hide-visible-overlay")
|
||||||
|
end
|
||||||
|
|
||||||
|
local texthooker_enabled = overrides.texthooker_enabled
|
||||||
|
if texthooker_enabled == nil then
|
||||||
|
texthooker_enabled = opts.texthooker_enabled
|
||||||
|
end
|
||||||
|
if texthooker_enabled then
|
||||||
|
table.insert(args, "--texthooker")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return args
|
||||||
|
end
|
||||||
|
|
||||||
|
run_control_command_async = function(action, overrides, callback)
|
||||||
|
local args = build_command_args(action, overrides)
|
||||||
|
subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
}, function(success, result, error)
|
||||||
|
local ok = success and (result == nil or result.status == 0)
|
||||||
|
if callback then
|
||||||
|
callback(ok, result, error)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_start_script_message_overrides(...)
|
||||||
|
local overrides = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
local token = select(i, ...)
|
||||||
|
if type(token) == "string" and token ~= "" then
|
||||||
|
local key, value = token:match("^([%w_%-]+)=(.+)$")
|
||||||
|
if key and value then
|
||||||
|
local normalized_key = key:lower()
|
||||||
|
if normalized_key == "backend" then
|
||||||
|
local backend = value:lower()
|
||||||
|
if backend == "auto" or backend == "hyprland" or backend == "sway" or backend == "x11" or backend == "macos" then
|
||||||
|
overrides.backend = backend
|
||||||
|
end
|
||||||
|
elseif normalized_key == "socket" or normalized_key == "socket_path" then
|
||||||
|
overrides.socket_path = value
|
||||||
|
elseif normalized_key == "texthooker" or normalized_key == "texthooker_enabled" then
|
||||||
|
local parsed = options_helper.coerce_bool(value, nil)
|
||||||
|
if parsed ~= nil then
|
||||||
|
overrides.texthooker_enabled = parsed
|
||||||
|
end
|
||||||
|
elseif normalized_key == "log-level" or normalized_key == "log_level" then
|
||||||
|
overrides.log_level = normalize_log_level(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return overrides
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensure_texthooker_running(callback)
|
||||||
|
if callback then
|
||||||
|
callback()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function start_overlay(overrides)
|
||||||
|
overrides = overrides or {}
|
||||||
|
if overrides.auto_start_trigger == true then
|
||||||
|
state.suppress_ready_overlay_restore = false
|
||||||
|
end
|
||||||
|
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if state.overlay_running then
|
||||||
|
if overrides.auto_start_trigger == true then
|
||||||
|
subminer_log("debug", "process", "Auto-start ignored because overlay is already running")
|
||||||
|
local socket_path = overrides.socket_path or opts.socket_path
|
||||||
|
local should_pause_until_ready = (
|
||||||
|
resolve_visible_overlay_startup()
|
||||||
|
and resolve_pause_until_ready()
|
||||||
|
and has_matching_mpv_ipc_socket(socket_path)
|
||||||
|
)
|
||||||
|
if should_pause_until_ready then
|
||||||
|
arm_auto_play_ready_gate()
|
||||||
|
else
|
||||||
|
disarm_auto_play_ready_gate()
|
||||||
|
end
|
||||||
|
local visibility_action = resolve_visible_overlay_startup()
|
||||||
|
and "show-visible-overlay"
|
||||||
|
or "hide-visible-overlay"
|
||||||
|
run_control_command_async(visibility_action, {
|
||||||
|
socket_path = socket_path,
|
||||||
|
log_level = overrides.log_level,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
end
|
||||||
|
subminer_log("info", "process", "Overlay already running")
|
||||||
|
show_osd("Already running")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local texthooker_enabled = overrides.texthooker_enabled
|
||||||
|
if texthooker_enabled == nil then
|
||||||
|
texthooker_enabled = opts.texthooker_enabled
|
||||||
|
end
|
||||||
|
local socket_path = overrides.socket_path or opts.socket_path
|
||||||
|
local should_pause_until_ready = (
|
||||||
|
overrides.auto_start_trigger == true
|
||||||
|
and resolve_visible_overlay_startup()
|
||||||
|
and resolve_pause_until_ready()
|
||||||
|
and has_matching_mpv_ipc_socket(socket_path)
|
||||||
|
)
|
||||||
|
if should_pause_until_ready then
|
||||||
|
arm_auto_play_ready_gate()
|
||||||
|
else
|
||||||
|
disarm_auto_play_ready_gate()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function launch_overlay_with_retry(attempt)
|
||||||
|
local args = build_command_args("start", overrides)
|
||||||
|
if attempt == 1 then
|
||||||
|
subminer_log("info", "process", "Starting overlay: " .. table.concat(args, " "))
|
||||||
|
else
|
||||||
|
subminer_log(
|
||||||
|
"warn",
|
||||||
|
"process",
|
||||||
|
"Retrying overlay start (attempt " .. tostring(attempt) .. "): " .. table.concat(args, " ")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if attempt == 1 and not state.auto_play_ready_gate_armed then
|
||||||
|
show_osd("Starting...")
|
||||||
|
end
|
||||||
|
state.overlay_running = true
|
||||||
|
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
}, function(success, result, error)
|
||||||
|
if not success or (result and result.status ~= 0) then
|
||||||
|
local reason = error or (result and result.stderr) or "unknown error"
|
||||||
|
if attempt < OVERLAY_START_MAX_ATTEMPTS then
|
||||||
|
mp.add_timeout(OVERLAY_START_RETRY_DELAY_SECONDS, function()
|
||||||
|
launch_overlay_with_retry(attempt + 1)
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
state.overlay_running = false
|
||||||
|
subminer_log("error", "process", "Overlay start failed after retries: " .. reason)
|
||||||
|
show_osd("Overlay start failed")
|
||||||
|
release_auto_play_ready_gate("overlay-start-failed")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if overrides.auto_start_trigger == true then
|
||||||
|
local visibility_action = resolve_visible_overlay_startup()
|
||||||
|
and "show-visible-overlay"
|
||||||
|
or "hide-visible-overlay"
|
||||||
|
run_control_command_async(visibility_action, {
|
||||||
|
socket_path = socket_path,
|
||||||
|
log_level = overrides.log_level,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
launch_overlay_with_retry(1)
|
||||||
|
if texthooker_enabled then
|
||||||
|
ensure_texthooker_running(function() end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function start_overlay_from_script_message(...)
|
||||||
|
local overrides = parse_start_script_message_overrides(...)
|
||||||
|
start_overlay(overrides)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function stop_overlay()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
run_control_command_async("stop", nil, function(ok, result)
|
||||||
|
if ok then
|
||||||
|
subminer_log("info", "process", "Overlay stopped")
|
||||||
|
else
|
||||||
|
subminer_log(
|
||||||
|
"warn",
|
||||||
|
"process",
|
||||||
|
"Stop command returned non-zero status: " .. tostring(result and result.status or "unknown")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
state.overlay_running = false
|
||||||
|
state.texthooker_running = false
|
||||||
|
disarm_auto_play_ready_gate()
|
||||||
|
show_osd("Stopped")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function hide_visible_overlay()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
state.suppress_ready_overlay_restore = true
|
||||||
|
|
||||||
|
run_control_command_async("hide-visible-overlay", nil, function(ok, result)
|
||||||
|
if ok then
|
||||||
|
subminer_log("info", "process", "Visible overlay hidden")
|
||||||
|
else
|
||||||
|
subminer_log(
|
||||||
|
"warn",
|
||||||
|
"process",
|
||||||
|
"Hide-visible-overlay command returned non-zero status: "
|
||||||
|
.. tostring(result and result.status or "unknown")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
disarm_auto_play_ready_gate()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function toggle_overlay()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
state.suppress_ready_overlay_restore = true
|
||||||
|
|
||||||
|
run_control_command_async("toggle-visible-overlay", nil, function(ok)
|
||||||
|
if not ok then
|
||||||
|
subminer_log("warn", "process", "Toggle command failed")
|
||||||
|
show_osd("Toggle failed")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function open_options()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
run_control_command_async("settings", nil, function(ok)
|
||||||
|
if ok then
|
||||||
|
subminer_log("info", "process", "Options window opened")
|
||||||
|
show_osd("Options opened")
|
||||||
|
else
|
||||||
|
subminer_log("warn", "process", "Failed to open options")
|
||||||
|
show_osd("Failed to open options")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function restart_overlay()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
subminer_log("info", "process", "Restarting overlay...")
|
||||||
|
show_osd("Restarting...")
|
||||||
|
|
||||||
|
run_control_command_async("stop", nil, function()
|
||||||
|
state.overlay_running = false
|
||||||
|
state.texthooker_running = false
|
||||||
|
disarm_auto_play_ready_gate()
|
||||||
|
|
||||||
|
local start_args = build_command_args("start")
|
||||||
|
subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
|
||||||
|
|
||||||
|
state.overlay_running = true
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = start_args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
}, function(success, result, error)
|
||||||
|
if not success or (result and result.status ~= 0) then
|
||||||
|
state.overlay_running = false
|
||||||
|
subminer_log(
|
||||||
|
"error",
|
||||||
|
"process",
|
||||||
|
"Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
|
||||||
|
)
|
||||||
|
show_osd("Restart failed")
|
||||||
|
else
|
||||||
|
show_osd("Restarted successfully")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
if opts.texthooker_enabled then
|
||||||
|
ensure_texthooker_running(function() end)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function check_status()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
show_osd("Status: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local status = state.overlay_running and "running" or "stopped"
|
||||||
|
show_osd("Status: overlay is " .. status)
|
||||||
|
subminer_log("info", "process", "Status check: overlay is " .. status)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function check_binary_available()
|
||||||
|
return binary.ensure_binary_available()
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
build_command_args = build_command_args,
|
||||||
|
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
|
||||||
|
run_control_command_async = run_control_command_async,
|
||||||
|
parse_start_script_message_overrides = parse_start_script_message_overrides,
|
||||||
|
ensure_texthooker_running = ensure_texthooker_running,
|
||||||
|
start_overlay = start_overlay,
|
||||||
|
start_overlay_from_script_message = start_overlay_from_script_message,
|
||||||
|
stop_overlay = stop_overlay,
|
||||||
|
hide_visible_overlay = hide_visible_overlay,
|
||||||
|
toggle_overlay = toggle_overlay,
|
||||||
|
open_options = open_options,
|
||||||
|
restart_overlay = restart_overlay,
|
||||||
|
check_status = check_status,
|
||||||
|
check_binary_available = check_binary_available,
|
||||||
|
notify_auto_play_ready = notify_auto_play_ready,
|
||||||
|
disarm_auto_play_ready_gate = disarm_auto_play_ready_gate,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
39
.config/mpv/scripts/subminer/state.lua
Normal file
39
.config/mpv/scripts/subminer/state.lua
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.new()
|
||||||
|
return {
|
||||||
|
overlay_running = false,
|
||||||
|
texthooker_running = false,
|
||||||
|
overlay_process = nil,
|
||||||
|
binary_available = false,
|
||||||
|
binary_path = nil,
|
||||||
|
detected_backend = nil,
|
||||||
|
hover_highlight = {
|
||||||
|
revision = -1,
|
||||||
|
payload = nil,
|
||||||
|
saved_sub_visibility = nil,
|
||||||
|
saved_secondary_sub_visibility = nil,
|
||||||
|
overlay_active = false,
|
||||||
|
cached_ass = nil,
|
||||||
|
clear_timer = nil,
|
||||||
|
last_hover_update_ts = 0,
|
||||||
|
},
|
||||||
|
aniskip = {
|
||||||
|
mal_id = nil,
|
||||||
|
title = nil,
|
||||||
|
episode = nil,
|
||||||
|
intro_start = nil,
|
||||||
|
intro_end = nil,
|
||||||
|
payload = nil,
|
||||||
|
payload_source = nil,
|
||||||
|
found = false,
|
||||||
|
prompt_shown = false,
|
||||||
|
},
|
||||||
|
auto_play_ready_gate_armed = false,
|
||||||
|
auto_play_ready_timeout = nil,
|
||||||
|
auto_play_ready_osd_timer = nil,
|
||||||
|
suppress_ready_overlay_restore = false,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
114
.config/mpv/scripts/subminer/ui.lua
Normal file
114
.config/mpv/scripts/subminer/ui.lua
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
local M = {}
|
||||||
|
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
|
||||||
|
local LEGACY_ANISKIP_BUTTON_KEY = "y-k"
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local input = ctx.input
|
||||||
|
local opts = ctx.opts
|
||||||
|
local process = ctx.process
|
||||||
|
local aniskip = ctx.aniskip
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
|
||||||
|
local function ensure_binary_for_menu()
|
||||||
|
if process.check_binary_available() then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function show_menu()
|
||||||
|
if not ensure_binary_for_menu() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local items = {
|
||||||
|
"Start overlay",
|
||||||
|
"Stop overlay",
|
||||||
|
"Toggle overlay",
|
||||||
|
"Open options",
|
||||||
|
"Restart overlay",
|
||||||
|
"Check status",
|
||||||
|
"Stats",
|
||||||
|
}
|
||||||
|
|
||||||
|
local actions = {
|
||||||
|
function()
|
||||||
|
process.start_overlay()
|
||||||
|
end,
|
||||||
|
function()
|
||||||
|
process.stop_overlay()
|
||||||
|
end,
|
||||||
|
function()
|
||||||
|
process.toggle_overlay()
|
||||||
|
end,
|
||||||
|
function()
|
||||||
|
process.open_options()
|
||||||
|
end,
|
||||||
|
function()
|
||||||
|
process.restart_overlay()
|
||||||
|
end,
|
||||||
|
function()
|
||||||
|
process.check_status()
|
||||||
|
end,
|
||||||
|
function()
|
||||||
|
mp.commandv("script-message", "subminer-stats-toggle")
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
input.select({
|
||||||
|
prompt = "SubMiner: ",
|
||||||
|
items = items,
|
||||||
|
submit = function(index)
|
||||||
|
if index and actions[index] then
|
||||||
|
actions[index]()
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function register_keybindings()
|
||||||
|
mp.add_key_binding("y-s", "subminer-start", function()
|
||||||
|
process.start_overlay()
|
||||||
|
end)
|
||||||
|
mp.add_key_binding("y-S", "subminer-stop", function()
|
||||||
|
process.stop_overlay()
|
||||||
|
end)
|
||||||
|
mp.add_key_binding("y-t", "subminer-toggle", function()
|
||||||
|
process.toggle_overlay()
|
||||||
|
end)
|
||||||
|
mp.add_key_binding("y-y", "subminer-menu", show_menu)
|
||||||
|
mp.add_key_binding("y-o", "subminer-options", function()
|
||||||
|
process.open_options()
|
||||||
|
end)
|
||||||
|
mp.add_key_binding("y-r", "subminer-restart", function()
|
||||||
|
process.restart_overlay()
|
||||||
|
end)
|
||||||
|
mp.add_key_binding("y-c", "subminer-status", function()
|
||||||
|
process.check_status()
|
||||||
|
end)
|
||||||
|
if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then
|
||||||
|
mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", function()
|
||||||
|
aniskip.skip_intro_now()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
if
|
||||||
|
opts.aniskip_button_key ~= LEGACY_ANISKIP_BUTTON_KEY
|
||||||
|
and opts.aniskip_button_key ~= DEFAULT_ANISKIP_BUTTON_KEY
|
||||||
|
then
|
||||||
|
mp.add_key_binding(LEGACY_ANISKIP_BUTTON_KEY, "subminer-skip-intro-fallback", function()
|
||||||
|
aniskip.skip_intro_now()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
show_menu = show_menu,
|
||||||
|
register_keybindings = register_keybindings,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
----------------------
|
|
||||||
-- #example ytdl_preload.conf
|
|
||||||
-- # make sure lines do not have trailing whitespace
|
|
||||||
-- # ytdl_opt has no sanity check and should be formatted exactly how it would appear in yt-dlp CLI, they are split into a key/value pair on whitespace
|
|
||||||
-- # at least on Windows, do not escape '\' in temp, just us a single one for each divider
|
|
||||||
|
|
||||||
-- #temp=R:\ytdltest
|
|
||||||
-- #ytdl_opt1=-r 50k
|
|
||||||
-- #ytdl_opt2=-N 5
|
|
||||||
-- #ytdl_opt#=etc
|
|
||||||
----------------------
|
|
||||||
local nextIndex
|
|
||||||
local caught = true
|
|
||||||
-- local pop = false
|
|
||||||
local ytdl = "yt-dlp"
|
|
||||||
local utils = require 'mp.utils'
|
|
||||||
|
|
||||||
local options = require 'mp.options'
|
|
||||||
local opts = {
|
|
||||||
temp = "/tmp/ytdl-preload",
|
|
||||||
ytdl_opt1 = "",
|
|
||||||
ytdl_opt2 = "",
|
|
||||||
ytdl_opt3 = "",
|
|
||||||
ytdl_opt4 = "",
|
|
||||||
ytdl_opt5 = "",
|
|
||||||
ytdl_opt6 = "",
|
|
||||||
ytdl_opt7 = "",
|
|
||||||
ytdl_opt8 = "",
|
|
||||||
ytdl_opt9 = "",
|
|
||||||
}
|
|
||||||
options.read_options(opts, "ytdl_preload")
|
|
||||||
local additionalOpts = {}
|
|
||||||
for k, v in pairs(opts) do
|
|
||||||
if k:find("ytdl_opt%d") and v ~= "" then
|
|
||||||
additionalOpts[k] = v
|
|
||||||
-- print("entry")
|
|
||||||
-- print(k .. v)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local cachePath = opts.temp
|
|
||||||
|
|
||||||
local chapter_list = {}
|
|
||||||
local json = ""
|
|
||||||
local filesToDelete = {}
|
|
||||||
|
|
||||||
local function exists(file)
|
|
||||||
local ok, err, code = os.rename(file, file)
|
|
||||||
if not ok then
|
|
||||||
if code == 13 then -- Permission denied, but it exists
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return ok, err
|
|
||||||
end
|
|
||||||
local function useNewLoadfile()
|
|
||||||
for _, c in pairs(mp.get_property_native("command-list")) do
|
|
||||||
if c["name"] == "loadfile" then
|
|
||||||
for _, a in pairs(c["args"]) do
|
|
||||||
if a["name"] == "index" then
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
--from ytdl_hook
|
|
||||||
local function time_to_secs(time_string)
|
|
||||||
local ret
|
|
||||||
local a, b, c = time_string:match("(%d+):(%d%d?):(%d%d)")
|
|
||||||
if a ~= nil then
|
|
||||||
ret = (a * 3600 + b * 60 + c)
|
|
||||||
else
|
|
||||||
a, b = time_string:match("(%d%d?):(%d%d)")
|
|
||||||
if a ~= nil then
|
|
||||||
ret = (a * 60 + b)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return ret
|
|
||||||
end
|
|
||||||
local function extract_chapters(data, video_length)
|
|
||||||
local ret = {}
|
|
||||||
for line in data:gmatch("[^\r\n]+") do
|
|
||||||
local time = time_to_secs(line)
|
|
||||||
if time and (time < video_length) then
|
|
||||||
table.insert(ret, { time = time, title = line })
|
|
||||||
end
|
|
||||||
end
|
|
||||||
table.sort(ret, function(a, b) return a.time < b.time end)
|
|
||||||
return ret
|
|
||||||
end
|
|
||||||
local function chapters()
|
|
||||||
if json.chapters then
|
|
||||||
for i = 1, #json.chapters do
|
|
||||||
local chapter = json.chapters[i]
|
|
||||||
local title = chapter.title or ""
|
|
||||||
if title == "" then
|
|
||||||
title = string.format('Chapter %02d', i)
|
|
||||||
end
|
|
||||||
table.insert(chapter_list, { time = chapter.start_time, title = title })
|
|
||||||
end
|
|
||||||
elseif not (json.description == nil) and not (json.duration == nil) then
|
|
||||||
chapter_list = extract_chapters(json.description, json.duration)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
--end ytdl_hook
|
|
||||||
local title = ""
|
|
||||||
local fVideo = ""
|
|
||||||
local fAudio = ""
|
|
||||||
local function load_files(dtitle, destination, audio, wait)
|
|
||||||
if wait then
|
|
||||||
if exists(destination .. ".mka") then
|
|
||||||
print("---wait success: found mka---")
|
|
||||||
audio = "audio-file=" .. destination .. '.mka,'
|
|
||||||
else
|
|
||||||
print("---could not find mka after wait, audio may be missing---")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
-- if audio ~= "" then
|
|
||||||
-- table.insert(filesToDelete, destination .. ".mka")
|
|
||||||
-- end
|
|
||||||
-- table.insert(filesToDelete, destination .. ".mkv")
|
|
||||||
dtitle = dtitle:gsub("-" .. ("[%w_-]"):rep(11) .. "$", "")
|
|
||||||
dtitle = dtitle:gsub("^" .. ("%d"):rep(10) .. "%-", "")
|
|
||||||
if useNewLoadfile() then
|
|
||||||
mp.commandv("loadfile", destination .. ".mkv", "append", -1,
|
|
||||||
audio .. 'force-media-title="' .. dtitle .. '",demuxer-max-back-bytes=1MiB,demuxer-max-bytes=3MiB,ytdl=no')
|
|
||||||
else
|
|
||||||
mp.commandv("loadfile", destination .. ".mkv", "append",
|
|
||||||
audio .. 'force-media-title="' .. dtitle .. '",demuxer-max-back-bytes=1MiB,demuxer-max-bytes=3MiB,ytdl=no') --,sub-file="..destination..".en.vtt") --in case they are not set up to autoload
|
|
||||||
end
|
|
||||||
mp.commandv("playlist_move", mp.get_property("playlist-count") - 1, nextIndex)
|
|
||||||
mp.commandv("playlist_remove", nextIndex + 1)
|
|
||||||
caught = true
|
|
||||||
title = ""
|
|
||||||
-- pop = true
|
|
||||||
end
|
|
||||||
|
|
||||||
local listenID = ""
|
|
||||||
local function listener(event)
|
|
||||||
if not caught and event.prefix == mp.get_script_name() and string.find(event.text, listenID) then
|
|
||||||
local destination = string.match(event.text, "%[download%] Destination: (.+).mkv") or
|
|
||||||
string.match(event.text, "%[download%] (.+).mkv has already been downloaded")
|
|
||||||
-- if destination then print("---"..cachePath) end;
|
|
||||||
if destination and string.find(destination, string.gsub(cachePath, '~/', '')) then
|
|
||||||
-- print(listenID)
|
|
||||||
mp.unregister_event(listener)
|
|
||||||
_, title = utils.split_path(destination)
|
|
||||||
local audio = ""
|
|
||||||
if fAudio == "" then
|
|
||||||
load_files(title, destination, audio, false)
|
|
||||||
else
|
|
||||||
if exists(destination .. ".mka") then
|
|
||||||
audio = "audio-file=" .. destination .. '.mka,'
|
|
||||||
load_files(title, destination, audio, false)
|
|
||||||
else
|
|
||||||
print("---expected mka but could not find it, waiting for 2 seconds---")
|
|
||||||
mp.add_timeout(2, function()
|
|
||||||
load_files(title, destination, audio, true)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
--from ytdl_hook
|
|
||||||
mp.add_hook("on_preloaded", 10, function()
|
|
||||||
if string.find(mp.get_property("path"), cachePath) then
|
|
||||||
chapters()
|
|
||||||
if next(chapter_list) ~= nil then
|
|
||||||
mp.set_property_native("chapter-list", chapter_list)
|
|
||||||
chapter_list = {}
|
|
||||||
json = ""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
--end ytdl_hook
|
|
||||||
function dump(o)
|
|
||||||
if type(o) == 'table' then
|
|
||||||
local s = '{ '
|
|
||||||
for k, v in pairs(o) do
|
|
||||||
if type(k) ~= 'number' then k = '"' .. k .. '"' end
|
|
||||||
s = s .. '[' .. k .. '] = ' .. dump(v) .. ','
|
|
||||||
end
|
|
||||||
return s .. '} '
|
|
||||||
else
|
|
||||||
return tostring(o)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function addOPTS(old)
|
|
||||||
for k, v in pairs(additionalOpts) do
|
|
||||||
-- print(k)
|
|
||||||
if string.find(v, "%s") then
|
|
||||||
for l, w in string.gmatch(v, "([-%w]+) (.+)") do
|
|
||||||
table.insert(old, l)
|
|
||||||
table.insert(old, w)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
table.insert(old, v)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
-- print(dump(old))
|
|
||||||
return old
|
|
||||||
end
|
|
||||||
|
|
||||||
local AudioDownloadHandle = {}
|
|
||||||
local VideoDownloadHandle = {}
|
|
||||||
local JsonDownloadHandle = {}
|
|
||||||
local function download_files(id, success, result, error)
|
|
||||||
if result.killed_by_us then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
local jfile = cachePath .. "/" .. id .. ".json"
|
|
||||||
|
|
||||||
local jfileIO = io.open(jfile, "w")
|
|
||||||
jfileIO:write(result.stdout)
|
|
||||||
jfileIO:close()
|
|
||||||
json = utils.parse_json(result.stdout)
|
|
||||||
-- print(dump(json))
|
|
||||||
if json.requested_downloads[1].requested_formats ~= nil then
|
|
||||||
local args = { ytdl, "--no-continue", "-q", "-f", fAudio, "--restrict-filenames", "--no-playlist", "--no-part",
|
|
||||||
"-o", cachePath .. "/" .. id .. "-%(title)s-%(id)s.mka", "--load-info-json", jfile }
|
|
||||||
args = addOPTS(args)
|
|
||||||
AudioDownloadHandle = mp.command_native_async({
|
|
||||||
name = "subprocess",
|
|
||||||
args = args,
|
|
||||||
playback_only = false
|
|
||||||
}, function()
|
|
||||||
end)
|
|
||||||
else
|
|
||||||
fAudio = ""
|
|
||||||
fVideo = fVideo:gsub("bestvideo", "best")
|
|
||||||
fVideo = fVideo:gsub("bv", "best")
|
|
||||||
end
|
|
||||||
|
|
||||||
local args = { ytdl, "--no-continue", "-f", fVideo .. '/best', "--restrict-filenames", "--no-playlist",
|
|
||||||
"--no-part", "-o", cachePath .. "/" .. id .. "-%(title)s-%(id)s.mkv", "--load-info-json", jfile }
|
|
||||||
args = addOPTS(args)
|
|
||||||
VideoDownloadHandle = mp.command_native_async({
|
|
||||||
name = "subprocess",
|
|
||||||
args = args,
|
|
||||||
playback_only = false
|
|
||||||
}, function()
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function DL()
|
|
||||||
local index = tonumber(mp.get_property("playlist-pos"))
|
|
||||||
if mp.get_property("playlist/" .. index .. "/filename"):find("/videos$") and mp.get_property("playlist/" .. index + 1 .. "/filename"):find("/shorts$") then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
if tonumber(mp.get_property("playlist-pos-1")) > 0 and mp.get_property("playlist-pos-1") ~= mp.get_property("playlist-count") then
|
|
||||||
nextIndex = index + 1
|
|
||||||
local nextFile = mp.get_property("playlist/" .. nextIndex .. "/filename")
|
|
||||||
if nextFile and caught and nextFile:find("://", 0, false) then
|
|
||||||
caught = false
|
|
||||||
mp.enable_messages("info")
|
|
||||||
mp.register_event("log-message", listener)
|
|
||||||
local ytFormat = mp.get_property("ytdl-format")
|
|
||||||
fVideo = string.match(ytFormat, '(.+)%+.+//?') or 'bestvideo'
|
|
||||||
fAudio = string.match(ytFormat, '.+%+(.+)//?') or 'bestaudio'
|
|
||||||
-- print("start"..nextFile)
|
|
||||||
listenID = tostring(os.time())
|
|
||||||
local args = { ytdl, "--dump-single-json", "--no-simulate", "--skip-download",
|
|
||||||
"--restrict-filenames",
|
|
||||||
"--no-playlist", "--sub-lang", "en", "--write-sub", "--no-part", "-o",
|
|
||||||
cachePath .. "/" .. listenID .. "-%(title)s-%(id)s.%(ext)s", nextFile }
|
|
||||||
args = addOPTS(args)
|
|
||||||
-- print(dump(args))
|
|
||||||
table.insert(filesToDelete, listenID)
|
|
||||||
JsonDownloadHandle = mp.command_native_async({
|
|
||||||
name = "subprocess",
|
|
||||||
args = args,
|
|
||||||
capture_stdout = true,
|
|
||||||
capture_stderr = true,
|
|
||||||
playback_only = false
|
|
||||||
}, function(...)
|
|
||||||
download_files(listenID, ...)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function clearCache()
|
|
||||||
-- print(pop)
|
|
||||||
|
|
||||||
--if pop == true then
|
|
||||||
mp.abort_async_command(AudioDownloadHandle)
|
|
||||||
mp.abort_async_command(VideoDownloadHandle)
|
|
||||||
mp.abort_async_command(JsonDownloadHandle)
|
|
||||||
-- for k, v in pairs(filesToDelete) do
|
|
||||||
-- print("remove: " .. v)
|
|
||||||
-- os.remove(v)
|
|
||||||
-- end
|
|
||||||
local ftd = io.open(cachePath .. "/temp.files", "a")
|
|
||||||
for k, v in pairs(filesToDelete) do
|
|
||||||
ftd:write(v .. "\n")
|
|
||||||
if package.config:sub(1, 1) ~= '/' then
|
|
||||||
os.execute('del /Q /F "' .. cachePath .. "\\" .. v .. '*"')
|
|
||||||
else
|
|
||||||
os.execute('rm -f ' .. cachePath .. "/" .. v .. "*")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
ftd:close()
|
|
||||||
print('clear')
|
|
||||||
mp.command("quit")
|
|
||||||
--end
|
|
||||||
end
|
|
||||||
mp.add_hook("on_unload", 50, function()
|
|
||||||
-- mp.abort_async_command(AudioDownloadHandle)
|
|
||||||
-- mp.abort_async_command(VideoDownloadHandle)
|
|
||||||
mp.abort_async_command(JsonDownloadHandle)
|
|
||||||
mp.unregister_event(listener)
|
|
||||||
caught = true
|
|
||||||
listenID = "resetYtdlPreloadListener"
|
|
||||||
-- print(listenID)
|
|
||||||
end)
|
|
||||||
|
|
||||||
local skipInitial
|
|
||||||
mp.observe_property("playlist-count", "number", function()
|
|
||||||
if skipInitial then
|
|
||||||
DL()
|
|
||||||
else
|
|
||||||
skipInitial = true
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
--from ytdl_hook
|
|
||||||
local platform_is_windows = (package.config:sub(1, 1) == "\\")
|
|
||||||
local o = {
|
|
||||||
exclude = "",
|
|
||||||
try_ytdl_first = false,
|
|
||||||
use_manifests = false,
|
|
||||||
all_formats = false,
|
|
||||||
force_all_formats = true,
|
|
||||||
ytdl_path = "",
|
|
||||||
}
|
|
||||||
local paths_to_search = { "yt-dlp", "yt-dlp_x86", "youtube-dl" }
|
|
||||||
--local options = require 'mp.options'
|
|
||||||
options.read_options(o, "ytdl_hook")
|
|
||||||
|
|
||||||
local separator = platform_is_windows and ";" or ":"
|
|
||||||
if o.ytdl_path:match("[^" .. separator .. "]") then
|
|
||||||
paths_to_search = {}
|
|
||||||
for path in o.ytdl_path:gmatch("[^" .. separator .. "]+") do
|
|
||||||
table.insert(paths_to_search, path)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function exec(args)
|
|
||||||
local ret = mp.command_native({
|
|
||||||
name = "subprocess",
|
|
||||||
args = args,
|
|
||||||
capture_stdout = true,
|
|
||||||
capture_stderr = true
|
|
||||||
})
|
|
||||||
return ret.status, ret.stdout, ret, ret.killed_by_us
|
|
||||||
end
|
|
||||||
|
|
||||||
local msg = require 'mp.msg'
|
|
||||||
local command = {}
|
|
||||||
for _, path in pairs(paths_to_search) do
|
|
||||||
-- search for youtube-dl in mpv's config dir
|
|
||||||
local exesuf = platform_is_windows and ".exe" or ""
|
|
||||||
local ytdl_cmd = mp.find_config_file(path .. exesuf)
|
|
||||||
if ytdl_cmd then
|
|
||||||
msg.verbose("Found youtube-dl at: " .. ytdl_cmd)
|
|
||||||
ytdl = ytdl_cmd
|
|
||||||
break
|
|
||||||
else
|
|
||||||
msg.verbose("No youtube-dl found with path " .. path .. exesuf .. " in config directories")
|
|
||||||
--search in PATH
|
|
||||||
command[1] = path
|
|
||||||
es, json, result, aborted = exec(command)
|
|
||||||
if result.error_string == "init" then
|
|
||||||
msg.verbose("youtube-dl with path " .. path .. exesuf .. " not found in PATH or not enough permissions")
|
|
||||||
else
|
|
||||||
msg.verbose("Found youtube-dl with path " .. path .. exesuf .. " in PATH")
|
|
||||||
ytdl = path
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
--end ytdl_hook
|
|
||||||
|
|
||||||
mp.register_event("start-file", DL)
|
|
||||||
mp.register_event("shutdown", clearCache)
|
|
||||||
local ftd = io.open(cachePath .. "/temp.files", "r")
|
|
||||||
while ftd ~= nil do
|
|
||||||
local line = ftd:read()
|
|
||||||
if line == nil or line == "" then
|
|
||||||
ftd:close()
|
|
||||||
io.open(cachePath .. "/temp.files", "w"):close()
|
|
||||||
break
|
|
||||||
end
|
|
||||||
-- print("DEL::"..line)
|
|
||||||
if package.config:sub(1, 1) ~= '/' then
|
|
||||||
os.execute('del /Q /F "' .. cachePath .. "\\" .. line .. '*" >nul 2>nul')
|
|
||||||
else
|
|
||||||
os.execute('rm -f ' .. cachePath .. "/" .. line .. "* &> /dev/null")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +29,29 @@ bindkey -v
|
|||||||
# Substring search
|
# Substring search
|
||||||
source /opt/homebrew/share/zsh-history-substring-search/zsh-history-substring-search.zsh
|
source /opt/homebrew/share/zsh-history-substring-search/zsh-history-substring-search.zsh
|
||||||
|
|
||||||
|
fzf-file-widget-smart-root() {
|
||||||
|
setopt localoptions pipefail no_aliases 2>/dev/null
|
||||||
|
|
||||||
|
local -a words
|
||||||
|
local last raw root sel
|
||||||
|
words=(${(z)LBUFFER})
|
||||||
|
last=${words[-1]}
|
||||||
|
raw=${(Q)last} # unquote shell word
|
||||||
|
root=${~raw} # expand ~
|
||||||
|
|
||||||
|
if [[ -n "$raw" && "$raw" == */ && -d "$root" ]]; then
|
||||||
|
sel="$(cd -- "$root" && __fzf_select)" || return
|
||||||
|
LBUFFER+="$sel"
|
||||||
|
else
|
||||||
|
LBUFFER+="$(__fzf_select)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
zle reset-prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
zle -N fzf-file-widget-smart-root
|
||||||
|
|
||||||
|
|
||||||
# Bind arrow keys for vi insert mode
|
# Bind arrow keys for vi insert mode
|
||||||
bindkey -M viins '^[[A' history-substring-search-up
|
bindkey -M viins '^[[A' history-substring-search-up
|
||||||
bindkey -M viins '^[[B' history-substring-search-down
|
bindkey -M viins '^[[B' history-substring-search-down
|
||||||
|
|||||||
Reference in New Issue
Block a user