Compare commits

...

10 Commits

Author SHA1 Message Date
sudacode 81b941fe8c fix(jellyfin): fix overlay toggle sync, redirect reload, and AppImage bi
- Sync visible-overlay state back to plugin via script messages to avoid toggle/hide drift
- Collapse duplicate toggle events within 250ms to prevent hide-then-show on single keypress
- Preserve manual hide across Jellyfin path-changing redirects even when media-title drops
- Rearm managed subtitle defaults on path-changing redirects
- Route toggleVisibleOverlay session binding through plugin toggle instead of app-side IPC
- Show Linux/Hyprland overlay passively (showInactive) to avoid stealing mpv keyboard focus
- Fix AppImage binary resolution to prefer $APPIMAGE env over mounted inner binary
- Add stats window layer management so delete/update dialogs appear above stats window
- Fix Jellyfin remote progress sync during Linux websocket reconnect windows
2026-05-23 01:45:09 -07:00
sudacode 80d05aef27 fix(jellyfin): fix remote progress sync, seek reporting, and startup sto
- arm active playback before loadfile with loadedMediaPath: null to suppress premature stop events
- force immediate progress report on seek-like position jumps at the mpv time-pos level
- send positionTicks and failed=false in reportStopped payload
- remove EventName from HTTP timeline payloads (websocket-only field)
- add startup grace window to drop stop events before media finishes loading
2026-05-22 23:41:05 -07:00
sudacode d1998797e9 fix(jellyfin): send explicit hide/show overlay instead of toggle
- Track overlay visibility in plugin state; y-t uses explicit hide/show commands when state is known
- Prevent paused Jellyfin playback from resuming on overlay hide
- Fix subtitle cache cleanup to only remove dirs after successful cleanup
2026-05-22 23:41:05 -07:00
sudacode 8de2613e4b fix(tokenizer): preserve known-word highlight when POS filters suppress
- Known-word cache matches now set isKnown=true even for tokens excluded by POS filters
- POS exclusion gate suppresses N+1, frequency, and JLPT only; known status is computed before the gate
- Jellyfin subtitle preload continues after cleanup failures instead of aborting
- Update config docs and option description to document the known-word bypass behavior
2026-05-22 23:41:05 -07:00
sudacode e8831bfbb8 fix(config): remove trailing commas from config.example.jsonc
- Strip trailing commas throughout both config.example.jsonc copies
- Reformat inline arrays to multi-line for JSON strictness
- Update Jellyfin subtitle preload and playback launch tests and impl
2026-05-22 23:41:05 -07:00
sudacode add09213bf docs(release): trim and consolidate prerelease notes for 0.15.0
- Remove breaking changes section and several redundant bullet points
- Consolidate per-platform updater notes into a single entry
- Normalize em-dash separators to hyphens in section headers
2026-05-22 23:41:05 -07:00
sudacode 2f2dfa3e91 fix(jellyfin): fix discovery loop, device identity, tray state, and Disc
- Derive device identity from OS hostname; remove legacy configurable client/device fields
- Prevent discovery playback from reloading active item, misreporting pause state, and duplicate overlay restores
- Restart stale tray discovery sessions without re-login when server drops SubMiner cast target
- Sync tray discovery checkbox state on Linux after CLI/startup/remote-session changes
- Stop Discord presence falling back to stream URLs; prime title before tokenized stream loads
- Fix picker library discovery when log level is above info
- Fix config.example.jsonc trailing commas and array formatting
2026-05-22 23:41:05 -07:00
sudacode 85d838ac96 fix(overlay): stabilize macOS focus handoff and sidebar Yomitan pause
- Keep overlay visible during macOS foreground probe after overlay blur
- Hold sidebar hover-pause while a Yomitan lookup popup remains open
2026-05-22 23:41:05 -07:00
sudacode d373de7a92 fix(jellyfin): show overlay, inject plugin, and fix stats title on playb
- Show visible overlay automatically during Jellyfin playback so subtitleStyle applies
- Inject bundled mpv plugin on auto-launch so keybindings work without overlay focus
- Group Jellyfin playback stats under item metadata (jellyfin://host/item/id) instead of stream URLs so episodes merge with matching local titles
- Mark ffsubsync unavailable in subsync modal for remote media paths
- Drain queued second-instance commands even when onReady throws
2026-05-22 23:41:05 -07:00
sudacode c4f99fec2f upgrade Electron 39→42 and fix Hyprland overlay z-order/placement (#79) 2026-05-22 23:22:51 -07:00
184 changed files with 5919 additions and 658 deletions
+135 -20
View File
@@ -18,11 +18,12 @@
"ws": "^8.19.0", "ws": "^8.19.0",
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.3.0", "@types/node": "^24.10.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"electron": "39.8.6", "electron": "42.2.0",
"electron-builder": "26.8.2", "electron-builder": "26.8.2",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"eslint": "^10.4.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
}, },
@@ -52,7 +53,7 @@
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="], "@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
"@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], "@electron/get": ["@electron/get@5.0.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA=="],
"@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="], "@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="],
@@ -116,10 +117,34 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="],
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="], "@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="],
"@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.2.7", "", {}, "sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA=="], "@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.2.7", "", {}, "sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
@@ -166,15 +191,21 @@
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], "@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="],
"@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
"@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],
@@ -194,6 +225,10 @@
"abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
@@ -294,6 +329,8 @@
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="],
"defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
@@ -326,7 +363,7 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@39.8.6", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-uWX6Jh5LmwL13VwOSKBjebI+ck+03GOwc8V2Sgbmr9pJVJ/cHfli/PkjXuRDr+hq+SLHQuT9mGHSIfScebApRA=="], "electron": ["electron@42.2.0", "", { "dependencies": { "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-b2Tc7sIKiZEl0tBVwFM5GJ+FT5KYhmy9QJHjx8BGVZPVW2SctXWEvrE959ElB56qw7H05dBkhlikDA1DmpaAMw=="],
"electron-builder": ["electron-builder@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-ieiiXPdgH3qrG6lcvy2mtnI5iEmAopmLuVRMSJ5j40weU0tgpNx0OAk9J5X5nnO0j9+KIkxHzwFZVUDk1U3aGw=="], "electron-builder": ["electron-builder@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-ieiiXPdgH3qrG6lcvy2mtnI5iEmAopmLuVRMSJ5j40weU0tgpNx0OAk9J5X5nnO0j9+KIkxHzwFZVUDk1U3aGw=="],
@@ -344,7 +381,7 @@
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
@@ -364,6 +401,22 @@
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="],
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
@@ -374,12 +427,22 @@
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
@@ -404,6 +467,8 @@
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
@@ -442,6 +507,8 @@
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
@@ -450,8 +517,12 @@
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="],
"is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
@@ -472,6 +543,8 @@
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
@@ -486,8 +559,12 @@
"lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"libsql": ["libsql@0.5.28", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.28", "@libsql/darwin-x64": "0.5.28", "@libsql/linux-arm-gnueabihf": "0.5.28", "@libsql/linux-arm-musleabihf": "0.5.28", "@libsql/linux-arm64-gnu": "0.5.28", "@libsql/linux-arm64-musl": "0.5.28", "@libsql/linux-x64-gnu": "0.5.28", "@libsql/linux-x64-musl": "0.5.28", "@libsql/win32-x64-msvc": "0.5.28" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg=="], "libsql": ["libsql@0.5.28", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.28", "@libsql/darwin-x64": "0.5.28", "@libsql/linux-arm-gnueabihf": "0.5.28", "@libsql/linux-arm-musleabihf": "0.5.28", "@libsql/linux-arm64-gnu": "0.5.28", "@libsql/linux-arm64-musl": "0.5.28", "@libsql/linux-x64-gnu": "0.5.28", "@libsql/linux-x64-musl": "0.5.28", "@libsql/win32-x64-msvc": "0.5.28" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.18.0", "", {}, "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA=="], "lodash": ["lodash@4.18.0", "", {}, "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
@@ -540,6 +617,8 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"node-abi": ["node-abi@4.28.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g=="], "node-abi": ["node-abi@4.28.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g=="],
@@ -560,16 +639,22 @@
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -588,6 +673,8 @@
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], "postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], "proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="],
@@ -700,13 +787,15 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], "undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="], "unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="],
@@ -726,6 +815,8 @@
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -748,14 +839,12 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@discordjs/rest/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
"@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="],
@@ -764,6 +853,8 @@
"@electron/windows-sign/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="], "@electron/windows-sign/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
@@ -774,6 +865,20 @@
"@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@types/cacheable-request/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/fs-extra/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/keyv/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/plist/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/responselike/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/ws/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/yauzl/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], "app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
@@ -786,8 +891,6 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"electron/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
@@ -800,22 +903,36 @@
"minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], "tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
"@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"@types/cacheable-request/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/fs-extra/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/keyv/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/plist/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/responselike/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/ws/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/yauzl/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -826,8 +943,6 @@
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"electron/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
-4
View File
@@ -1,4 +0,0 @@
type: changed
area: settings
- Simplified configuration option rows by hiding raw config paths and placing the live/restart status beside each option title.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: runtime
- Updated the bundled Electron runtime from 39.8.6 to 42.2.0, moving SubMiner back onto a supported Electron release line.
@@ -0,0 +1,4 @@
type: fixed
area: integrations
- Prevented Discord Rich Presence from falling back to Jellyfin stream URLs, and primed Jellyfin playback titles before loading tokenized streams so presence shows the show/episode title
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Refreshed Linux overlay placement after leaving mpv fullscreen so Hyprland keeps the visible overlay aligned to the player.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: overlay
- Kept the Hyprland visible overlay stacked above mpv after mpv receives focus from clicks or overlay movement.
- Suspended the visible overlay while the in-player stats window is open, then restored it mouse-passive after stats closes.
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Prevented Jellyfin discovery playback from reloading the active item, misreporting paused mpv playback as still playing, retrying startup unpause after playback is paused again, unpausing after a manual `y-t` overlay toggle during startup, repeatedly restoring the overlay from duplicate ready signals, missing delayed Japanese subtitle selection on startup, letting later German/Russian subtitle loads steal the selected Japanese track, and spawning long-lived sidebar ffmpeg extractors against Jellyfin stream URLs.
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Derived Jellyfin cast device identity from the OS hostname, always reports the client as SubMiner, and ignores legacy configurable Jellyfin client/device identity fields so multiple SubMiner installs no longer share the same remote-session identity.
+11
View File
@@ -0,0 +1,11 @@
type: fixed
area: jellyfin
- Fixed Jellyfin `y-t` overlay hide so the plugin sends an explicit hide command when it knows the overlay is visible, avoiding overlay reloads and paused playback resumes.
- Kept that manual hide sticky across Jellyfin stream redirects that change mpv's path, even when the redirected URL drops mpv's media title.
- Re-armed managed subtitle defaults during those path-changing redirects so Japanese primary subtitles can load on the redirected stream.
- Routed visible-overlay shortcuts and app-side visibility changes back through the mpv plugin so SubMiner overlay toggling stays independent of Jellyfin playback controls.
- Collapsed duplicate visible-overlay toggle events so Hyprland does not process one physical shortcut as hide-then-show.
- Kept passive Linux/Hyprland visible-overlay shows from taking keyboard focus away from mpv/Jellyfin.
- Made Jellyfin external subtitle selection tolerate transient mpv `track-list` read failures and numeric string track IDs so Japanese subtitles are selected after preload on Linux.
- Fixed AppImage-launched Jellyfin playback controls so mpv sends overlay commands to the running SubMiner app-control socket instead of the mounted Electron binary.
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Fixed Jellyfin remote controller visibility and progress syncing for mpv/SubMiner seek jumps, stopped sessions, startup path changes, and Linux websocket reconnect windows.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Grouped Jellyfin playback stats under Jellyfin item metadata instead of stream URLs, so watched episodes merge with matching local library titles and keep clean display names.
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Keep the Jellyfin discovery tray checkbox in sync on Linux after tray, CLI, or startup remote-session changes, with a visible check mark when Linux tray hosts ignore native checkbox rendering.
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Restarted stale Jellyfin tray discovery sessions when the server no longer lists the SubMiner cast target, avoiding a needless Jellyfin re-login.
@@ -1,4 +0,0 @@
type: changed
area: config
- Reorganized each known-words deck row in the Settings window into a card with the deck name on its own header line so longer deck names stay readable instead of being truncated.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updater
- Clarified that beta/RC update checks are controlled by `updates.channel`; set it to `"prerelease"` to receive beta/RC updates.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Kept playback paused for Yomitan lookup popups opened from the subtitle sidebar when popup auto-pause is enabled.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Stats: Fixed in-player stats layering so delete confirmations, overlay modals, and update-check dialogs appear above the stats window.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Kept Jellyfin picker library discovery working when the running app log level is above info.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: jellyfin
- Showed the visible subtitle overlay automatically during Jellyfin playback so configured `subtitleStyle` appearance applies to Jellyfin subtitles.
- Injected the bundled mpv plugin when SubMiner auto-launches mpv for Jellyfin playback, restoring mpv-side keybindings without needing overlay focus.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Kept the macOS visible overlay stable when clicking from the overlay back into mpv.
@@ -1,7 +0,0 @@
type: changed
area: launcher
breaking: true
- Renamed the SubMiner Configuration window to the Settings window across the UI, tray menu, docs, and CLI verbiage.
- Replaced the `--config` flag and `subminer config` (no action) entry points with `--settings` and `subminer settings`. The `subminer config` subcommand now only accepts `path` or `show`.
- Removed the `--settings` alias that previously opened the bundled Yomitan settings popup. Use `--yomitan` to open Yomitan settings.
-4
View File
@@ -1,4 +0,0 @@
type: changed
area: config
- Settings: reorganized playback, shortcut, WebSocket, tracking, Jellyfin, character dictionary, and Discord presence controls in the settings modal.
+1 -5
View File
@@ -519,7 +519,7 @@
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes. "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. "decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
}, // Known words setting. }, // Known words setting.
"behavior": { "behavior": {
@@ -636,14 +636,10 @@
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup. "recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login. "username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing. "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
"autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false "autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false
"remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions.
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
+4 -6
View File
@@ -1050,6 +1050,7 @@ Known-word cache policy:
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory. - Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
- The cache is automatically invalidated when the configured scope changes (for example, when deck changes). - The cache is automatically invalidated when the configured scope changes (for example, when deck changes).
- Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set `ankiConnect.knownWords.matchMode` to `"surface"` for raw subtitle text matching. - Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set `ankiConnect.knownWords.matchMode` to `"surface"` for raw subtitle text matching.
- A known-word cache match always receives known-word highlighting, even when part-of-speech filters suppress N+1, frequency, or JLPT annotations for that token.
- Legacy moved keys under `ankiConnect.nPlusOne` (`highlightEnabled`, `refreshMinutes`, `matchMode`, `decks`, `knownWord`) and older `ankiConnect.behavior.nPlusOne*` keys are deprecated and only kept for backward compatibility. - Legacy moved keys under `ankiConnect.nPlusOne` (`highlightEnabled`, `refreshMinutes`, `matchMode`, `decks`, `knownWord`) and older `ankiConnect.behavior.nPlusOne*` keys are deprecated and only kept for backward compatibility.
- Legacy top-level `ankiConnect` migration keys (for example `audioField`, `generateAudio`, `imageType`) are compatibility-only, validated before mapping, and ignored with a warning when invalid. - Legacy top-level `ankiConnect` migration keys (for example `audioField`, `generateAudio`, `imageType`) are compatibility-only, validated before mapping, and ignored with a warning when invalid.
- If AnkiConnect is unreachable, the cache remains in its previous state and an on-screen/system status message is shown. - If AnkiConnect is unreachable, the cache remains in its previous state and an on-screen/system status message is shown.
@@ -1258,7 +1259,6 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
"remoteControlEnabled": true, "remoteControlEnabled": true,
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true,
"autoAnnounce": false, "autoAnnounce": false,
"remoteControlDeviceName": "SubMiner",
"defaultLibraryId": "", "defaultLibraryId": "",
"directPlayPreferred": true, "directPlayPreferred": true,
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
@@ -1273,21 +1273,17 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
| `serverUrl` | string (URL) | Jellyfin server base URL | | `serverUrl` | string (URL) | Jellyfin server base URL |
| `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 | | `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 |
| `username` | string | Default username used by `--jellyfin-login` | | `username` | string | Default username used by `--jellyfin-login` |
| `deviceId` | string | Client device id sent in auth headers (default: `subminer`) |
| `clientName` | string | Client name sent in auth headers (default: `SubMiner`) |
| `clientVersion` | string | Client version sent in auth headers (default: `0.1.0`) |
| `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted | | `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted |
| `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support | | `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support |
| `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires `jellyfin.enabled` and `remoteControlEnabled`) | | `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires `jellyfin.enabled` and `remoteControlEnabled`) |
| `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) | | `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) |
| `remoteControlDeviceName` | string | Device name shown in Jellyfin cast/device lists |
| `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers | | `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers |
| `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons | | `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons |
| `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding | | `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding |
| `directPlayContainers` | string[] | Container allowlist for direct play decisions | | `directPlayContainers` | string[] | Container allowlist for direct play decisions |
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) | | `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime. The Settings window also hides low-level client identity and default library fields (`deviceId`, `clientName`, `clientVersion`, and `defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior. Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken`, `jellyfin.userId`, `jellyfin.clientName`, `jellyfin.deviceId`, `jellyfin.clientVersion`, and `jellyfin.remoteControlDeviceName` config keys are not resolver-backed settings in the current runtime. SubMiner reports the Jellyfin client as `SubMiner`, derives the Jellyfin device id and visible device name from the OS hostname, and owns the client version internally. The Settings window also hides low-level default library fields (`defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior.
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed. - On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
@@ -1304,6 +1300,8 @@ See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to
Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`. Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`.
Jellyfin playback auto-launched through SubMiner loads the mpv plugin the same way regular playback does, and shows the visible subtitle overlay automatically so `subtitleStyle` applies to subtitles selected from Jellyfin.
When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session. When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session.
### Discord Rich Presence ### Discord Rich Presence
+4 -3
View File
@@ -29,7 +29,6 @@ SubMiner includes an optional Jellyfin CLI integration for:
"remoteControlEnabled": true, "remoteControlEnabled": true,
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true,
"autoAnnounce": false, "autoAnnounce": false,
"remoteControlDeviceName": "SubMiner",
"defaultLibraryId": "", "defaultLibraryId": "",
"pullPictures": false, "pullPictures": false,
"iconCacheDir": "/tmp/subminer-jellyfin-icons", "iconCacheDir": "/tmp/subminer-jellyfin-icons",
@@ -50,7 +49,7 @@ subminer jellyfin -l \
--password 'your-password' --password 'your-password'
``` ```
`subminer jellyfin` opens the setup window. It pre-fills the server URL from the configured server, a recent successful server, or the local default. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored. `subminer jellyfin` opens the setup window. It pre-fills the server URL from the configured server, a recent successful server, or the local default. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username, and refreshes recent servers. Passwords are never stored.
3. List libraries: 3. List libraries:
@@ -70,7 +69,7 @@ Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
subminer jellyfin -d subminer jellyfin -d
``` ```
After Jellyfin is enabled with a server URL and SubMiner is already running, the tray menu shows `Jellyfin Discovery`. Use that checkbox to start or stop discovery for the current runtime session without changing config. If the stored login session is missing or expired, starting discovery shows a warning and setup remains the path to refresh credentials. It does not survive app restart. After Jellyfin is enabled with a server URL and SubMiner is already running, the tray menu shows `Jellyfin Discovery`. Use that checkbox to start or stop discovery for the current runtime session without changing config. By default, Jellyfin sees the cast target as the OS hostname (`uname -n` on Linux). If the stored login session is missing or expired, starting discovery shows a warning and setup remains the path to refresh credentials. It does not survive app restart.
Stop discovery session/app: Stop discovery session/app:
@@ -124,6 +123,8 @@ Optional stream overrides:
- If direct play is not selected/available, SubMiner requests a Jellyfin transcoded stream (`master.m3u8`) using `jellyfin.transcodeVideoCodec`. - If direct play is not selected/available, SubMiner requests a Jellyfin transcoded stream (`master.m3u8`) using `jellyfin.transcodeVideoCodec`.
- Resume position (`PlaybackPositionTicks`) is applied via mpv seek. - Resume position (`PlaybackPositionTicks`) is applied via mpv seek.
- Media title is set in mpv as `[Jellyfin/<mode>] <title>`. - Media title is set in mpv as `[Jellyfin/<mode>] <title>`.
- When SubMiner auto-launches mpv for Jellyfin playback, it injects the bundled mpv plugin unless an installed SubMiner mpv plugin is already present. This keeps mpv-side keybindings available without clicking the overlay first.
- Jellyfin playback shows the SubMiner visible overlay before selecting subtitle tracks, so `subtitleStyle` controls the rendered subtitle appearance. Use the overlay toggle shortcut if you want to hide it for a session.
## Cast To Device Mode (jellyfin-mpv-shim style) ## Cast To Device Mode (jellyfin-mpv-shim style)
+2
View File
@@ -190,6 +190,8 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
3. For alass, select a reference subtitle track from the video. 3. For alass, select a reference subtitle track from the video.
4. SubMiner runs the sync and reloads the corrected subtitle. 4. SubMiner runs the sync and reloads the corrected subtitle.
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs.
Install the sync tools separately — see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found. Install the sync tools separately — see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found.
## N+1 Word Highlighting ## N+1 Word Highlighting
+1 -5
View File
@@ -519,7 +519,7 @@
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes. "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. "decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
}, // Known words setting. }, // Known words setting.
"behavior": { "behavior": {
@@ -636,14 +636,10 @@
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup. "recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login. "username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing. "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
"autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false "autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false
"remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions.
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
+1 -1
View File
@@ -114,7 +114,7 @@ Automatic checks log failures quietly so playback is not interrupted.
**"SubMiner is up to date" but a prerelease exists** **"SubMiner is up to date" but a prerelease exists**
SubMiner defaults to stable GitHub releases. Set `updates.channel` to `"prerelease"` in `config.jsonc` when you want update checks to include beta and RC releases. SubMiner uses the configured release channel for update checks. Set `updates.channel` to `"prerelease"` in `config.jsonc` when you want update checks to include beta and RC releases.
**Launcher update shows a sudo command** **Launcher update shows a sudo command**
+3 -28
View File
@@ -3,40 +3,13 @@ import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { withProcessExitIntercept } from '../test-support/exit-intercept.js';
import { import {
applyInvocationsToArgs, applyInvocationsToArgs,
applyRootOptionsToArgs, applyRootOptionsToArgs,
createDefaultArgs, createDefaultArgs,
} from './args-normalizer.js'; } from './args-normalizer.js';
class ExitSignal extends Error {
code: number;
constructor(code: number) {
super(`exit:${code}`);
this.code = code;
}
}
function withProcessExitIntercept(callback: () => void): ExitSignal {
const originalExit = process.exit;
try {
process.exit = ((code?: number) => {
throw new ExitSignal(code ?? 0);
}) as typeof process.exit;
callback();
} catch (error) {
if (error instanceof ExitSignal) {
return error;
}
throw error;
} finally {
process.exit = originalExit;
}
throw new Error('expected process.exit');
}
function withTempDir<T>(fn: (dir: string) => T): T { function withTempDir<T>(fn: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-args-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-args-'));
try { try {
@@ -106,6 +79,7 @@ test('applyRootOptionsToArgs rejects unsupported targets', () => {
assert.equal(error.code, 1); assert.equal(error.code, 1);
assert.match(error.message, /exit:1/); assert.match(error.message, /exit:1/);
assert.match(error.stderr, /Not a file, directory, or supported URL/);
}); });
test('applyInvocationsToArgs maps config and jellyfin invocation state', () => { test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
@@ -231,6 +205,7 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
}); });
assert.equal(error.code, 1); assert.equal(error.code, 1);
assert.match(error.stderr, /Unknown config action: \(none\)/);
}); });
test('applyInvocationsToArgs maps texthooker browser-open request', () => { test('applyInvocationsToArgs maps texthooker browser-open request', () => {
+16 -8
View File
@@ -361,6 +361,21 @@ export function classifyJellyfinChildSelection(
fail('Selected Jellyfin item is not playable.'); fail('Selected Jellyfin item is not playable.');
} }
export function buildForwardedJellyfinAppArgs(args: Args, appArgs: string[]): string[] {
const forwarded = [...appArgs];
const serverOverride = sanitizeServerUrl(args.jellyfinServer || '');
if (serverOverride) {
forwarded.push('--jellyfin-server', serverOverride);
}
if (args.passwordStore) {
forwarded.push('--password-store', args.passwordStore);
}
if (!forwarded.some((arg) => arg === '--log-level' || arg.startsWith('--log-level='))) {
forwarded.push('--log-level', args.logLevel);
}
return forwarded;
}
async function runAppJellyfinListCommand( async function runAppJellyfinListCommand(
appPath: string, appPath: string,
args: Args, args: Args,
@@ -384,14 +399,7 @@ async function runAppJellyfinCommand(
appArgs: string[], appArgs: string[],
label: string, label: string,
): Promise<{ status: number; output: string; error: string; logOffset: number }> { ): Promise<{ status: number; output: string; error: string; logOffset: number }> {
const forwardedBase = [...appArgs]; const forwardedBase = buildForwardedJellyfinAppArgs(args, appArgs);
const serverOverride = sanitizeServerUrl(args.jellyfinServer || '');
if (serverOverride) {
forwardedBase.push('--jellyfin-server', serverOverride);
}
if (args.passwordStore) {
forwardedBase.push('--password-store', args.passwordStore);
}
const readLogAppendedSince = (offset: number): string => { const readLogAppendedSince = (offset: number): string => {
const logPath = getMpvLogPath(); const logPath = getMpvLogPath();
+22
View File
@@ -17,6 +17,7 @@ import {
parseEpisodePathFromDisplay, parseEpisodePathFromDisplay,
buildRootSearchGroups, buildRootSearchGroups,
classifyJellyfinChildSelection, classifyJellyfinChildSelection,
buildForwardedJellyfinAppArgs,
} from './jellyfin.js'; } from './jellyfin.js';
type RunResult = { type RunResult = {
@@ -878,6 +879,27 @@ test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => {
]); ]);
}); });
test('buildForwardedJellyfinAppArgs forces app log level for parseable list output', () => {
const forwarded = buildForwardedJellyfinAppArgs(
{
jellyfinServer: 'https://jf.example.test/',
passwordStore: 'gnome-libsecret',
logLevel: 'info',
} as never,
['--jellyfin-libraries'],
);
assert.deepEqual(forwarded, [
'--jellyfin-libraries',
'--jellyfin-server',
'https://jf.example.test',
'--password-store',
'gnome-libsecret',
'--log-level',
'info',
]);
});
test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => { test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
const parsed = parseJellyfinErrorFromAppOutput(` const parsed = parseJellyfinErrorFromAppOutput(`
[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning [subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
+3 -28
View File
@@ -7,6 +7,7 @@ import net from 'node:net';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import type { Args } from './types'; import type { Args } from './types';
import { getAppControlSocketPath } from '../src/shared/app-control'; import { getAppControlSocketPath } from '../src/shared/app-control';
import { withProcessExitIntercept } from './test-support/exit-intercept.js';
import { import {
buildConfiguredMpvDefaultArgs, buildConfiguredMpvDefaultArgs,
buildMpvBackendArgs, buildMpvBackendArgs,
@@ -28,34 +29,6 @@ import {
} from './mpv'; } from './mpv';
import * as mpvModule from './mpv'; import * as mpvModule from './mpv';
class ExitSignal extends Error {
code: number;
constructor(code: number) {
super(`exit:${code}`);
this.code = code;
}
}
function withProcessExitIntercept(callback: () => void): ExitSignal {
const originalExit = process.exit;
try {
process.exit = ((code?: number) => {
throw new ExitSignal(code ?? 0);
}) as typeof process.exit;
callback();
} catch (error) {
if (error instanceof ExitSignal) {
return error;
}
throw error;
} finally {
process.exit = originalExit;
}
throw new Error('expected process.exit');
}
function createTempSocketPath(): { dir: string; socketPath: string } { function createTempSocketPath(): { dir: string; socketPath: string } {
const baseDir = path.join(process.cwd(), '.tmp', 'launcher-mpv-tests'); const baseDir = path.join(process.cwd(), '.tmp', 'launcher-mpv-tests');
fs.mkdirSync(baseDir, { recursive: true }); fs.mkdirSync(baseDir, { recursive: true });
@@ -393,6 +366,7 @@ test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', ()
}); });
assert.equal(error.code, 1); assert.equal(error.code, 1);
assert.match(error.stderr, /Failed to launch texthooker mode/);
}); });
test('launchTexthookerOnly forwards browser-open request to app command', () => { test('launchTexthookerOnly forwards browser-open request to app command', () => {
@@ -833,6 +807,7 @@ test('startOverlay uses caller config dir for app control socket discovery', asy
const { dir, socketPath } = createTempSocketPath(); const { dir, socketPath } = createTempSocketPath();
const configDir = path.join(dir, 'launcher-config'); const configDir = path.join(dir, 'launcher-config');
const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' }); const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' });
fs.mkdirSync(configDir, { recursive: true });
const appPath = path.join(dir, 'fake-subminer.sh'); const appPath = path.join(dir, 'fake-subminer.sh');
const appInvocationsPath = path.join(dir, 'app-invocations.log'); const appInvocationsPath = path.join(dir, 'app-invocations.log');
const receivedControlArgv: string[][] = []; const receivedControlArgv: string[][] = [];
+6 -28
View File
@@ -1,34 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { parseArgs } from './config'; import { parseArgs } from './config';
import { withProcessExitIntercept } from './test-support/exit-intercept.js';
class ExitSignal extends Error {
code: number;
constructor(code: number) {
super(`exit:${code}`);
this.code = code;
}
}
function withProcessExitIntercept(callback: () => void): ExitSignal {
const originalExit = process.exit;
try {
process.exit = ((code?: number) => {
throw new ExitSignal(code ?? 0);
}) as typeof process.exit;
callback();
} catch (error) {
if (error instanceof ExitSignal) {
return error;
}
throw error;
} finally {
process.exit = originalExit;
}
throw new Error('expected parseArgs to exit');
}
test('parseArgs captures passthrough args for app subcommand', () => { test('parseArgs captures passthrough args for app subcommand', () => {
const parsed = parseArgs(['app', '--anilist', '--log-level', 'debug'], 'subminer', {}); const parsed = parseArgs(['app', '--anilist', '--log-level', 'debug'], 'subminer', {});
@@ -131,7 +104,9 @@ test('parseArgs rejects removed config open and launch actions', () => {
}); });
assert.equal(openExit.code, 1); assert.equal(openExit.code, 1);
assert.match(openExit.stderr, /Unknown config action: open/);
assert.equal(exit.code, 1); assert.equal(exit.code, 1);
assert.match(exit.stderr, /Unknown config action: launch/);
}); });
test('parseArgs requires an explicit action for the config subcommand', () => { test('parseArgs requires an explicit action for the config subcommand', () => {
@@ -140,6 +115,7 @@ test('parseArgs requires an explicit action for the config subcommand', () => {
}); });
assert.equal(exit.code, 1); assert.equal(exit.code, 1);
assert.match(exit.stderr, /Unknown config action: \(none\)/);
}); });
test('parseArgs maps mpv idle action', () => { test('parseArgs maps mpv idle action', () => {
@@ -180,6 +156,7 @@ test('parseArgs rejects conflicting dictionary candidate and selection modes', (
}); });
assert.equal(exit.code, 1); assert.equal(exit.code, 1);
assert.match(exit.stderr, /Dictionary --candidates and --select cannot be combined/);
}); });
test('parseArgs maps stats command and log-level override', () => { test('parseArgs maps stats command and log-level override', () => {
@@ -243,6 +220,7 @@ test('parseArgs rejects cleanup-only stats flags without cleanup action', () =>
assert.equal(error.code, 1); assert.equal(error.code, 1);
assert.match(error.message, /exit:1/); assert.match(error.message, /exit:1/);
assert.match(error.stderr, /Stats --vocab and --lifetime flags require the cleanup action/);
}); });
test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => { test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => {
+47
View File
@@ -0,0 +1,47 @@
export class ExitSignal extends Error {
code: number;
stderr: string;
constructor(code: number, stderr: string) {
super(`exit:${code}`);
this.code = code;
this.stderr = stderr;
}
}
function stderrChunkToString(chunk: string | Uint8Array, encoding?: BufferEncoding): string {
if (typeof chunk === 'string') return chunk;
return Buffer.from(chunk).toString(encoding);
}
export function withProcessExitIntercept(callback: () => void): ExitSignal {
const originalExit = process.exit;
const originalStderrWrite = process.stderr.write;
let stderr = '';
try {
process.stderr.write = ((chunk: string | Uint8Array, ...args: unknown[]) => {
const encoding = typeof args[0] === 'string' ? (args[0] as BufferEncoding) : undefined;
stderr += stderrChunkToString(chunk, encoding);
const writeCallback = args.find((arg): arg is (error?: Error | null) => void => {
return typeof arg === 'function';
});
writeCallback?.();
return true;
}) as typeof process.stderr.write;
process.exit = ((code?: number) => {
throw new ExitSignal(code ?? 0, stderr);
}) as typeof process.exit;
callback();
} catch (error) {
if (error instanceof ExitSignal) {
return error;
}
throw error;
} finally {
process.stderr.write = originalStderrWrite;
process.exit = originalExit;
}
throw new Error('expected process.exit');
}
+3 -2
View File
@@ -121,11 +121,12 @@
"ws": "^8.19.0" "ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.3.0", "@types/node": "^24.10.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"electron": "39.8.6", "electron": "42.2.0",
"electron-builder": "26.8.2", "electron-builder": "26.8.2",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"eslint": "^10.4.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
+7
View File
@@ -114,6 +114,13 @@ function M.create(ctx)
end end
end end
if not environment.is_windows() then
local appimage_path = resolve_binary_candidate(os.getenv("APPIMAGE"))
if appimage_path and appimage_path ~= "" then
return appimage_path
end
end
return nil return nil
end end
+50 -1
View File
@@ -33,6 +33,20 @@ function M.create(ctx)
return nil return nil
end end
local function resolve_media_title()
local media_title = mp.get_property("media-title")
if type(media_title) == "string" and media_title ~= "" then
return media_title
end
local filename = mp.get_property("filename")
if type(filename) == "string" and filename ~= "" then
return filename
end
return nil
end
local function is_reload_end_file(reason) local function is_reload_end_file(reason)
return reason == "reload" or reason == "redirect" return reason == "reload" or reason == "redirect"
end end
@@ -125,6 +139,10 @@ function M.create(ctx)
local function on_start_file() local function on_start_file()
if state.pending_reload_media_identity ~= nil then if state.pending_reload_media_identity ~= nil then
local media_identity = resolve_media_identity()
if media_identity ~= nil and media_identity ~= state.pending_reload_media_identity then
rearm_managed_subtitle_load_defaults()
end
return return
end end
rearm_managed_subtitle_load_defaults() rearm_managed_subtitle_load_defaults()
@@ -132,24 +150,47 @@ function M.create(ctx)
local function on_file_loaded() local function on_file_loaded()
local media_identity = resolve_media_identity() local media_identity = resolve_media_identity()
local media_title = resolve_media_title()
local retry_generation = next_auto_start_retry_generation() local retry_generation = next_auto_start_retry_generation()
local previous_media_identity = state.current_media_identity local previous_media_identity = state.current_media_identity
local pending_reload_title = state.pending_reload_media_title
local pending_reload_reason = state.pending_reload_reason
local same_media_reload = ( local same_media_reload = (
media_identity ~= nil media_identity ~= nil
and state.pending_reload_media_identity ~= nil and state.pending_reload_media_identity ~= nil
and media_identity == state.pending_reload_media_identity and media_identity == state.pending_reload_media_identity
) or (
state.pending_reload_media_identity ~= nil
and media_title ~= nil
and pending_reload_title ~= nil
and media_title == pending_reload_title
) or (
pending_reload_reason == "redirect"
and state.pending_reload_media_identity ~= nil
) )
local same_media_loaded = ( local same_media_loaded = (
media_identity ~= nil media_identity ~= nil
and previous_media_identity ~= nil and previous_media_identity ~= nil
and media_identity == previous_media_identity and media_identity == previous_media_identity
) )
local new_media_loaded = media_identity ~= nil and not same_media_reload and not same_media_loaded
state.pending_reload_media_identity = nil state.pending_reload_media_identity = nil
state.pending_reload_media_title = nil
state.pending_reload_reason = nil
state.current_media_identity = media_identity state.current_media_identity = media_identity
state.current_media_title = media_title
if new_media_loaded then
state.suppress_ready_overlay_restore = false
end
if same_media_reload then if same_media_reload then
subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload") subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload")
if state.overlay_running and resolve_auto_start_enabled() and process.has_matching_mpv_ipc_socket(opts.socket_path) then if
state.overlay_running
and not state.suppress_ready_overlay_restore
and resolve_auto_start_enabled()
and process.has_matching_mpv_ipc_socket(opts.socket_path)
then
process.run_control_command_async("show-visible-overlay", { process.run_control_command_async("show-visible-overlay", {
socket_path = opts.socket_path, socket_path = opts.socket_path,
}) })
@@ -182,7 +223,10 @@ function M.create(ctx)
hover.clear_hover_overlay() hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate() process.disarm_auto_play_ready_gate()
state.current_media_identity = nil state.current_media_identity = nil
state.current_media_title = nil
state.pending_reload_media_identity = nil state.pending_reload_media_identity = nil
state.pending_reload_media_title = nil
state.pending_reload_reason = nil
end end
local function register_lifecycle_hooks() local function register_lifecycle_hooks()
@@ -198,11 +242,16 @@ function M.create(ctx)
local reason = type(event) == "table" and event.reason or nil local reason = type(event) == "table" and event.reason or nil
if is_reload_end_file(reason) then if is_reload_end_file(reason) then
state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity() state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity()
state.pending_reload_media_title = state.current_media_title or resolve_media_title()
state.pending_reload_reason = reason
return return
end end
next_auto_start_retry_generation() next_auto_start_retry_generation()
state.current_media_identity = nil state.current_media_identity = nil
state.current_media_title = nil
state.pending_reload_media_identity = nil state.pending_reload_media_identity = nil
state.pending_reload_media_title = nil
state.pending_reload_reason = nil
if state.overlay_running and reason ~= "quit" then if state.overlay_running and reason ~= "quit" then
process.hide_visible_overlay() process.hide_visible_overlay()
end end
+6
View File
@@ -17,6 +17,12 @@ function M.create(ctx)
mp.register_script_message("subminer-toggle", function() mp.register_script_message("subminer-toggle", function()
process.toggle_overlay() process.toggle_overlay()
end) end)
mp.register_script_message("subminer-visible-overlay-hidden", function()
process.record_visible_overlay_visibility(false)
end)
mp.register_script_message("subminer-visible-overlay-shown", function()
process.record_visible_overlay_visibility(true)
end)
mp.register_script_message("subminer-menu", function() mp.register_script_message("subminer-menu", function()
ui.show_menu() ui.show_menu()
end) end)
+95 -14
View File
@@ -7,6 +7,7 @@ local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..." local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready" local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15 local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
local DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS = 0.25
function M.create(ctx) function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
@@ -31,6 +32,16 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_visible_overlay, false) return options_helper.coerce_bool(raw_visible_overlay, false)
end end
local function resolve_auto_start_visibility_action()
if resolve_visible_overlay_startup() then
if state.suppress_ready_overlay_restore then
return nil
end
return "show-visible-overlay"
end
return "hide-visible-overlay"
end
local function resolve_pause_until_ready() local function resolve_pause_until_ready()
local raw_pause_until_ready = opts.auto_start_pause_until_ready local raw_pause_until_ready = opts.auto_start_pause_until_ready
if raw_pause_until_ready == nil then if raw_pause_until_ready == nil then
@@ -67,6 +78,49 @@ function M.create(ctx)
return DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS return DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS
end end
local function record_visible_overlay_action(action)
if action == "show-visible-overlay" then
state.visible_overlay_requested = true
state.suppress_ready_overlay_restore = false
elseif action == "hide-visible-overlay" then
state.visible_overlay_requested = false
elseif action == "toggle-visible-overlay" and state.visible_overlay_requested ~= nil then
state.visible_overlay_requested = not state.visible_overlay_requested
if state.visible_overlay_requested then
state.suppress_ready_overlay_restore = false
end
end
end
local function record_visible_overlay_visibility(visible)
if visible then
state.visible_overlay_requested = true
state.suppress_ready_overlay_restore = false
return
end
state.visible_overlay_requested = false
state.suppress_ready_overlay_restore = true
end
local function should_ignore_duplicate_visible_overlay_toggle()
if type(mp.get_time) ~= "function" then
return false
end
local now = mp.get_time()
if type(now) ~= "number" then
return false
end
local previous = state.last_visible_overlay_toggle_time
state.last_visible_overlay_toggle_time = now
if type(previous) ~= "number" then
return false
end
local elapsed = now - previous
return elapsed >= 0 and elapsed < DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS
end
local function normalize_socket_path(path) local function normalize_socket_path(path)
if type(path) ~= "string" then if type(path) ~= "string" then
return nil return nil
@@ -129,7 +183,7 @@ function M.create(ctx)
local function release_auto_play_ready_gate(reason) local function release_auto_play_ready_gate(reason)
if not state.auto_play_ready_gate_armed then if not state.auto_play_ready_gate_armed then
return return false
end end
local should_resume_playback = state.auto_play_ready_should_resume_playback == true local should_resume_playback = state.auto_play_ready_should_resume_playback == true
disarm_auto_play_ready_gate({ resume_playback = false }) disarm_auto_play_ready_gate({ resume_playback = false })
@@ -140,6 +194,7 @@ function M.create(ctx)
else else
subminer_log("info", "process", "Startup gate ready; leaving playback paused: " .. tostring(reason or "ready")) subminer_log("info", "process", "Startup gate ready; leaving playback paused: " .. tostring(reason or "ready"))
end end
return true
end end
local function arm_auto_play_ready_gate() local function arm_auto_play_ready_gate()
@@ -179,9 +234,12 @@ function M.create(ctx)
end end
local function notify_auto_play_ready() local function notify_auto_play_ready()
release_auto_play_ready_gate("tokenization-ready") local released_ready_gate = release_auto_play_ready_gate("tokenization-ready")
local force_ready_overlay_restore = state.force_ready_overlay_restore == true local force_ready_overlay_restore = state.force_ready_overlay_restore == true
state.force_ready_overlay_restore = false state.force_ready_overlay_restore = false
if not released_ready_gate and not force_ready_overlay_restore then
return
end
if state.suppress_ready_overlay_restore and not force_ready_overlay_restore then if state.suppress_ready_overlay_restore and not force_ready_overlay_restore then
return return
end end
@@ -224,7 +282,7 @@ function M.create(ctx)
local should_show_visible = overrides.show_visible_overlay local should_show_visible = overrides.show_visible_overlay
if should_show_visible == nil then if should_show_visible == nil then
should_show_visible = resolve_visible_overlay_startup() should_show_visible = resolve_visible_overlay_startup() and not state.suppress_ready_overlay_restore
end end
if should_show_visible then if should_show_visible then
table.insert(args, "--show-visible-overlay") table.insert(args, "--show-visible-overlay")
@@ -303,6 +361,7 @@ function M.create(ctx)
end end
run_control_command_async = function(action, overrides, callback) run_control_command_async = function(action, overrides, callback)
record_visible_overlay_action(action)
local args = build_command_args(action, overrides) local args = build_command_args(action, overrides)
local command = build_subprocess_command(args) local command = build_subprocess_command(args)
subminer_log("debug", "process", "Control command: " .. table.concat(args, " ")) subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
@@ -399,9 +458,6 @@ function M.create(ctx)
local function start_overlay(overrides) local function start_overlay(overrides)
overrides = overrides or {} overrides = overrides or {}
if overrides.auto_start_trigger == true then
state.suppress_ready_overlay_restore = false
end
if not binary.ensure_binary_available() then if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found") subminer_log("error", "binary", "SubMiner binary not found")
@@ -424,13 +480,13 @@ function M.create(ctx)
elseif not state.auto_play_ready_gate_armed then elseif not state.auto_play_ready_gate_armed then
disarm_auto_play_ready_gate() disarm_auto_play_ready_gate()
end end
local visibility_action = resolve_visible_overlay_startup() local visibility_action = resolve_auto_start_visibility_action()
and "show-visible-overlay" if visibility_action ~= nil then
or "hide-visible-overlay"
run_control_command_async(visibility_action, { run_control_command_async(visibility_action, {
socket_path = socket_path, socket_path = socket_path,
log_level = overrides.log_level, log_level = overrides.log_level,
}) })
end
return return
end end
subminer_log("info", "process", "Overlay already running") subminer_log("info", "process", "Overlay already running")
@@ -495,14 +551,14 @@ function M.create(ctx)
end end
if overrides.auto_start_trigger == true then if overrides.auto_start_trigger == true then
local visibility_action = resolve_visible_overlay_startup() local visibility_action = resolve_auto_start_visibility_action()
and "show-visible-overlay" if visibility_action ~= nil then
or "hide-visible-overlay"
run_control_command_async(visibility_action, { run_control_command_async(visibility_action, {
socket_path = socket_path, socket_path = socket_path,
log_level = overrides.log_level, log_level = overrides.log_level,
}) })
end end
end
end) end)
end end
@@ -546,7 +602,8 @@ function M.create(ctx)
show_osd("Stopped") show_osd("Stopped")
end end
local function hide_visible_overlay() local function hide_visible_overlay(options)
options = options or {}
if not binary.ensure_binary_available() then if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found") subminer_log("error", "binary", "SubMiner binary not found")
return return
@@ -566,7 +623,9 @@ function M.create(ctx)
end end
end) end)
disarm_auto_play_ready_gate() disarm_auto_play_ready_gate({
resume_playback = options.resume_playback ~= false,
})
end end
local function toggle_overlay() local function toggle_overlay()
@@ -575,7 +634,28 @@ function M.create(ctx)
show_osd("Error: binary not found") show_osd("Error: binary not found")
return return
end end
if should_ignore_duplicate_visible_overlay_toggle() then
subminer_log("debug", "process", "Ignoring duplicate visible overlay toggle")
return
end
if state.visible_overlay_requested == true then
state.suppress_ready_overlay_restore = true state.suppress_ready_overlay_restore = true
hide_visible_overlay({ resume_playback = false })
return
end
if state.visible_overlay_requested == false then
state.suppress_ready_overlay_restore = false
disarm_auto_play_ready_gate({ resume_playback = false })
run_control_command_async("show-visible-overlay", nil, function(ok)
if not ok then
subminer_log("warn", "process", "Show-visible-overlay command failed")
show_osd("Toggle failed")
end
end)
return
end
state.suppress_ready_overlay_restore = true
disarm_auto_play_ready_gate({ resume_playback = false })
run_control_command_async("toggle-visible-overlay", nil, function(ok) run_control_command_async("toggle-visible-overlay", nil, function(ok)
if not ok then if not ok then
@@ -705,6 +785,7 @@ function M.create(ctx)
build_command_args = build_command_args, build_command_args = build_command_args,
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket, has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
run_control_command_async = run_control_command_async, run_control_command_async = run_control_command_async,
record_visible_overlay_visibility = record_visible_overlay_visibility,
run_binary_command_async = run_binary_command_async, run_binary_command_async = run_binary_command_async,
parse_start_script_message_overrides = parse_start_script_message_overrides, parse_start_script_message_overrides = parse_start_script_message_overrides,
ensure_texthooker_running = ensure_texthooker_running, ensure_texthooker_running = ensure_texthooker_running,
+5
View File
@@ -312,6 +312,11 @@ function M.create(ctx)
return return
end end
if binding.actionId == "toggleVisibleOverlay" and type(process.toggle_overlay) == "function" then
process.toggle_overlay()
return
end
if binding.actionId == "copySubtitleMultiple" or binding.actionId == "mineSentenceMultiple" then if binding.actionId == "copySubtitleMultiple" or binding.actionId == "mineSentenceMultiple" then
start_numeric_selection(binding.actionId, numeric_selection_timeout_ms, binding.key.modifiers) start_numeric_selection(binding.actionId, numeric_selection_timeout_ms, binding.key.modifiers)
return return
+5
View File
@@ -35,8 +35,13 @@ function M.new()
auto_play_ready_osd_timer = nil, auto_play_ready_osd_timer = nil,
suppress_ready_overlay_restore = false, suppress_ready_overlay_restore = false,
force_ready_overlay_restore = false, force_ready_overlay_restore = false,
visible_overlay_requested = nil,
last_visible_overlay_toggle_time = nil,
current_media_identity = nil, current_media_identity = nil,
current_media_title = nil,
pending_reload_media_identity = nil, pending_reload_media_identity = nil,
pending_reload_media_title = nil,
pending_reload_reason = nil,
auto_start_retry_generation = 0, auto_start_retry_generation = 0,
session_binding_generation = 0, session_binding_generation = 0,
session_binding_names = {}, session_binding_names = {},
+4 -18
View File
@@ -1,10 +1,6 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release. > This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
## Highlights ## Highlights
### Breaking Changes
- **Settings Window:** The Configuration window is now called the Settings window everywhere — UI, tray menu, docs, and CLI. `--config` and `subminer config` (no action) are replaced by `--settings` and `subminer settings`; `subminer config` now only accepts `path` or `show`. The `--settings` alias that previously opened the Yomitan settings popup is removed — use `--yomitan` instead.
### Added ### Added
- **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`. Options are organized into Appearance, Behavior, Anki, Input, and Integration sections with learned keybinding controls, AnkiConnect-backed deck/field/note-type pickers, and live reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, and Anki field mappings. AI and translation fields remain supported in config files only. - **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`. Options are organized into Appearance, Behavior, Anki, Input, and Integration sections with learned keybinding controls, AnkiConnect-backed deck/field/note-type pickers, and live reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, and Anki field mappings. AI and translation fields remain supported in config files only.
@@ -17,16 +13,12 @@
### Changed ### Changed
- **Settings Window:** Option rows no longer display raw config paths; live/restart status is shown inline beside each option title. Known-words deck rows are now cards with the deck name on a separate header line so long names remain readable. Playback, shortcut, WebSocket, tracking, Jellyfin, character dictionary, and Discord presence controls have been reorganized.
- **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`. Existing configs are migrated automatically. Sidebar appearance is now configured via `subtitleSidebar.css`; the default subtitle font is updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`. - **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`. Existing configs are migrated automatically. Sidebar appearance is now configured via `subtitleSidebar.css`; the default subtitle font is updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`.
- **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys are still accepted with deprecation warnings. Existing configs that had known-word highlighting enabled retain N+1 highlighting; new configs leave N+1 disabled unless `ankiConnect.nPlusOne.enabled` is explicitly set. - **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys are still accepted with deprecation warnings. Existing configs that had known-word highlighting enabled retain N+1 highlighting; new configs leave N+1 disabled unless `ankiConnect.nPlusOne.enabled` is explicitly set.
- **Linux Updater:** Tray "Check for Updates" now automatically installs the new AppImage via `electron-updater`, matching the macOS and Windows tray flow. AppImages managed by a system package (e.g. AUR `/opt/SubMiner`) and non-AppImage launches fall back to the GitHub-asset flow. - **Linux Updater:** Tray "Check for Updates" now automatically installs the new AppImage via `electron-updater`, matching the macOS and Windows tray flow. AppImages managed by a system package (e.g. AUR `/opt/SubMiner`) and non-AppImage launches fall back to the GitHub-asset flow.
- **Subsync:** Always opens the manual subtitle picker. The `subsync.defaultMode` config option has been removed.
- **Jellyfin:** The server presets dropdown in Jellyfin setup is removed; setup now shows a single editable server URL field. - **Jellyfin:** The server presets dropdown in Jellyfin setup is removed; setup now shows a single editable server URL field.
- **AniSkip:** The key binding setting now uses click-to-learn key capture instead of raw text entry. - **AniSkip:** The key binding setting now uses click-to-learn key capture instead of raw text entry.
@@ -47,17 +39,13 @@
- **Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits. - **Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits.
- **Updater — Linux:** The tray app now uses GitHub release metadata for update checks instead of the native Electron updater, preventing crashes. `subminer -u` performs updates independently of any running tray instance and correctly reports "up to date" without downloading assets when no newer release exists. Update check traffic is routed through `/usr/bin/curl` to avoid Electron network-service crashes during video startup. - **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata instead of the native Electron updater, `subminer -u` can update independently of the tray app, macOS update dialogs come to the front reliably, unsupported builds show a manual-install message, and Windows keeps the native NSIS update path while routing updater HTTP through the main process. GitHub release lookups now avoid Electron networking on Linux and macOS.
- **Updater — macOS:** Update dialogs now reliably come to the front when launched from `subminer --update`. Builds that cannot install native updates show a manual-install message instead of an inapplicable restart prompt. Signed macOS builds remain on the native `electron-updater`/Squirrel path; supplemental GitHub release lookups are routed through `/usr/bin/curl`, eliminating the last Electron-networking path from background update checks. - **Setup - macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed - both return control to the terminal without requiring Ctrl+C.
- **Updater — Windows:** Automatic updates keep the native `electron-updater`/NSIS install path enabled while routing updater HTTP through main-process fetch, avoiding the delayed app exit seen shortly after launch.
- **Setup — macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed — both return control to the terminal without requiring Ctrl+C.
- **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; the settings window uses a close-only menu to prevent accidentally quitting the tray app; an in-page close button is provided on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can now close correctly without mpv running. - **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; the settings window uses a close-only menu to prevent accidentally quitting the tray app; an in-page close button is provided on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can now close correctly without mpv running.
- **Launcher Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently. - **Launcher - Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently.
- **Launcher:** Launcher-opened videos now reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused when attaching to a running background app until subtitle priming and tokenization readiness complete. Launcher-owned tray apps close after playback ends. - **Launcher:** Launcher-opened videos now reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused when attaching to a running background app until subtitle priming and tokenization readiness complete. Launcher-owned tray apps close after playback ends.
@@ -79,9 +67,7 @@
- **Settings:** Settings window search now searches across all categories, narrows correctly on multi-word terms, and hides settings with dedicated editors. Live saves for subtitle CSS declarations apply immediately to open overlays. Legacy subtitle appearance options and hover token colors are automatically migrated into `subtitleStyle.css`. - **Settings:** Settings window search now searches across all categories, narrows correctly on multi-word terms, and hides settings with dedicated editors. Live saves for subtitle CSS declarations apply immediately to open overlays. Legacy subtitle appearance options and hover token colors are automatically migrated into `subtitleStyle.css`.
- **Config:** User config files are preserved during legacy compatibility handling. The note-fields note-type picker now defaults to the configured Anki deck's note type, falling back to `Kiku`, then `Lapis`, then blank for manual selection. - **Build - Linux:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation.
- **Build — Linux:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation.
### Docs ### Docs
+25
View File
@@ -68,6 +68,31 @@ local function create_binary_module(config)
return binary return binary
end end
do
local appimage_path = "/home/tester/.local/bin/SubMiner.AppImage"
local mounted_binary_path = "/tmp/.mount_SubMiner/SubMiner"
local resolved = with_env({
APPIMAGE = appimage_path,
}, function()
local binary = create_binary_module({
is_windows = false,
binary_path = mounted_binary_path,
entries = {
[appimage_path] = "file",
[mounted_binary_path] = "file",
},
})
return binary.find_binary()
end)
assert_equal(
resolved,
appimage_path,
"linux resolver should prefer APPIMAGE over the mounted AppImage inner binary"
)
end
do do
local binary = create_binary_module({ local binary = create_binary_module({
is_windows = true, is_windows = true,
+17
View File
@@ -23,6 +23,7 @@ local recorded = {
async_calls = {}, async_calls = {},
mpv_commands = {}, mpv_commands = {},
osd = {}, osd = {},
overlay_toggles = 0,
} }
local mp = {} local mp = {}
@@ -68,6 +69,14 @@ local ctx = {
return { return {
numericSelectionTimeoutMs = 3000, numericSelectionTimeoutMs = 3000,
bindings = { bindings = {
{
key = {
code = "KeyO",
modifiers = { "alt", "shift" },
},
actionType = "session-action",
actionId = "toggleVisibleOverlay",
},
{ {
key = { key = {
code = "KeyS", code = "KeyS",
@@ -253,6 +262,9 @@ local ctx = {
run_binary_command_async = function(args) run_binary_command_async = function(args)
recorded.async_calls[#recorded.async_calls + 1] = args recorded.async_calls[#recorded.async_calls + 1] = args
end, end,
toggle_overlay = function()
recorded.overlay_toggles = recorded.overlay_toggles + 1
end,
}, },
environment = { environment = {
resolve_session_bindings_artifact_path = function() resolve_session_bindings_artifact_path = function()
@@ -318,6 +330,11 @@ local expected_cli_bindings = {
{ keys = "w", flag = "--mark-watched" }, { keys = "w", flag = "--mark-watched" },
} }
local visible_overlay_toggle = find_binding("Alt+O")
assert_true(visible_overlay_toggle ~= nil, "visible overlay session binding should register")
visible_overlay_toggle.fn()
assert_true(recorded.overlay_toggles == 1, "visible overlay session binding should use plugin toggle")
for _, expected in ipairs(expected_cli_bindings) do for _, expected in ipairs(expected_cli_bindings) do
local binding = find_binding(expected.keys) local binding = find_binding(expected.keys)
assert_true(binding ~= nil, "default session action should register " .. expected.keys) assert_true(binding ~= nil, "default session action should register " .. expected.keys)
+327 -6
View File
@@ -201,7 +201,7 @@ local function run_plugin_scenario(config)
end end
function mp.set_osd_ass(...) end function mp.set_osd_ass(...) end
function mp.get_time() function mp.get_time()
return 0 return config.now or 0
end end
function mp.commandv(...) end function mp.commandv(...) end
function mp.set_property_native(name, value) function mp.set_property_native(name, value)
@@ -623,16 +623,18 @@ local binary_path = "/tmp/subminer-binary"
local appimage_path = "/tmp/SubMiner.AppImage" local appimage_path = "/tmp/SubMiner.AppImage"
do do
local recorded, err = run_plugin_scenario({ local scenario = {
process_list = "", process_list = "",
option_overrides = { option_overrides = {
binary_path = binary_path, binary_path = binary_path,
auto_start = "no", auto_start = "no",
}, },
now = 20,
files = { files = {
[binary_path] = true, [binary_path] = true,
}, },
}) }
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err)) assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err))
assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered") assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered")
recorded.script_messages["subminer-start"]("texthooker=no") recorded.script_messages["subminer-start"]("texthooker=no")
@@ -683,6 +685,174 @@ do
) )
end end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/jellyfin-app-toggle-initial.m3u8",
media_title = "Jellyfin App Toggle",
paused = true,
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for app-side hide Jellyfin redirect: " .. tostring(err))
fire_event(recorded, "start-file")
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-visible-overlay-hidden"]()
fire_event(recorded, "end-file", { reason = "redirect" })
scenario.path = "/media/jellyfin-app-toggle-final.m3u8"
scenario.media_title = ""
fire_event(recorded, "start-file")
fire_event(recorded, "file-loaded")
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"app-side hide sync should suppress path-changing Jellyfin redirect visible overlay reassertion"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"app-side hide sync followed by Jellyfin redirect should keep paused playback paused"
)
end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/jellyfin-duplicate-toggle.m3u8",
media_title = "Jellyfin Duplicate Toggle",
paused = true,
now = 10,
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for duplicate visible overlay toggle: " .. tostring(err))
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-toggle"]()
recorded.script_messages["subminer-toggle"]()
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1,
"duplicate same-tick visible overlay toggles should hide once"
)
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"duplicate same-tick visible overlay toggles should not immediately show the overlay again"
)
scenario.now = 10.5
recorded.script_messages["subminer-toggle"]()
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
"later visible overlay toggle should still show after duplicate suppression window"
)
end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "no",
},
now = 20,
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for visible overlay state sync scenario: " .. tostring(err))
assert_true(
recorded.script_messages["subminer-visible-overlay-hidden"] ~= nil,
"hidden visibility sync message should be registered"
)
assert_true(
recorded.script_messages["subminer-visible-overlay-shown"] ~= nil,
"shown visibility sync message should be registered"
)
recorded.script_messages["subminer-visible-overlay-hidden"]()
recorded.script_messages["subminer-toggle"]()
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"toggle after app-side hide should explicitly show SubMiner overlay through plugin state"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0,
"toggle after app-side hide should avoid app-side visible overlay toggle"
)
scenario.now = 20.5
recorded.script_messages["subminer-visible-overlay-shown"]()
recorded.script_messages["subminer-toggle"]()
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1,
"toggle after app-side show should explicitly hide SubMiner overlay through plugin state"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/jellyfin-stream.m3u8",
media_title = "Jellyfin Episode",
paused = true,
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for y-t hide visible overlay scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
local toggle_binding = nil
for _, candidate in ipairs(recorded.key_bindings) do
if candidate.name == "subminer-toggle" then
toggle_binding = candidate
break
end
end
assert_true(toggle_binding ~= nil, "y-t toggle binding should be registered")
toggle_binding.fn()
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1,
"y-t should hide the known visible overlay explicitly instead of app-side toggle"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0,
"y-t should avoid app-side toggle when plugin knows the overlay is visible"
)
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"manual y-t hide should suppress duplicate auto-start and ready-time visible overlay reassertion"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"manual y-t hide should not resume paused Jellyfin playback"
)
end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -1396,7 +1566,7 @@ do
"duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running" "duplicate pause-until-ready auto-start should not issue duplicate --start commands while overlay is already running"
) )
assert_true( assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 4, count_control_calls(recorded.async_calls, "--show-visible-overlay") == 3,
"duplicate pause-until-ready auto-start should re-assert visible overlay on initial start, ready, and later file load" "duplicate pause-until-ready auto-start should re-assert visible overlay on initial start, ready, and later file load"
) )
assert_true( assert_true(
@@ -1471,6 +1641,33 @@ do
) )
end end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for duplicate autoplay-ready scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered")
recorded.script_messages["subminer-autoplay-ready"]()
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
"duplicate autoplay-ready signals should not repeatedly spawn visible overlay restore commands"
)
end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -1523,14 +1720,22 @@ do
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered") assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
recorded.script_messages["subminer-toggle"]() recorded.script_messages["subminer-toggle"]()
assert_true( assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 1, count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 1,
"manual toggle should use explicit visible-overlay toggle command" "manual toggle-off should hide a known visible overlay explicitly"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0,
"manual toggle-off should avoid app-side toggle when plugin knows the overlay is visible"
) )
recorded.script_messages["subminer-autoplay-ready"]() recorded.script_messages["subminer-autoplay-ready"]()
assert_true( assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"manual toggle-off before readiness should suppress ready-time visible overlay restore" "manual toggle-off before readiness should suppress ready-time visible overlay restore"
) )
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"manual toggle-off before readiness should not resume playback when readiness arrives"
)
end end
do do
@@ -1564,6 +1769,122 @@ do
) )
end end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/jellyfin-stream.m3u8",
media_title = "Jellyfin Episode",
paused = true,
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for manual hide duplicate auto-start scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
recorded.script_messages["subminer-toggle"]()
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
"manual toggle-off should suppress duplicate auto-start visible overlay reassertion"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"manual toggle-off followed by duplicate auto-start should keep paused playback paused"
)
end
do
local media_path = "/media/jellyfin-redirect.m3u8"
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = media_path,
media_title = "Jellyfin Redirect",
paused = true,
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for manual hide same-media reload scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
recorded.script_messages["subminer-toggle"]()
fire_event(recorded, "end-file", { reason = "redirect" })
fire_event(recorded, "file-loaded")
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
"manual toggle-off should suppress same-media reload visible overlay reassertion"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"manual toggle-off followed by same-media reload should keep paused playback paused"
)
end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/jellyfin-redirect-initial.m3u8",
media_title = "Jellyfin Redirect",
paused = true,
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for manual hide path-changing Jellyfin redirect: " .. tostring(err))
fire_event(recorded, "start-file")
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
recorded.script_messages["subminer-toggle"]()
fire_event(recorded, "end-file", { reason = "redirect" })
scenario.path = "/media/jellyfin-redirect-final.m3u8"
scenario.media_title = ""
fire_event(recorded, "start-file")
fire_event(recorded, "file-loaded")
assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
"manual toggle-off should suppress path-changing Jellyfin redirect visible overlay reassertion even if media-title drops"
)
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 0,
"manual toggle-off followed by path-changing Jellyfin reload should keep paused playback paused"
)
assert_true(
count_property_set(recorded.property_sets, "sid", "auto") == 2,
"path-changing Jellyfin redirect should rearm primary subtitle selection before mpv loads tracks"
)
assert_true(
count_property_set(recorded.property_sets, "secondary-sid", "auto") == 2,
"path-changing Jellyfin redirect should rearm secondary subtitle selection before mpv loads tracks"
)
end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
+10 -3
View File
@@ -74,7 +74,10 @@ test('loads defaults when config is missing', () => {
assert.equal(config.jellyfin.remoteControlEnabled, true); assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true); assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, false); assert.equal(config.jellyfin.autoAnnounce, false);
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner'); assert.equal('clientName' in config.jellyfin, false);
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
assert.equal('deviceId' in config.jellyfin, false);
assert.equal('clientVersion' in config.jellyfin, false);
assert.equal(config.ai.enabled, false); assert.equal(config.ai.enabled, false);
assert.equal(config.ai.apiKeyCommand, ''); assert.equal(config.ai.apiKeyCommand, '');
assert.equal(config.texthooker.openBrowser, false); assert.equal(config.texthooker.openBrowser, false);
@@ -825,7 +828,7 @@ test('parses anilist.characterDictionary.collapsibleSections booleans and warns
); );
}); });
test('parses jellyfin remote control fields', () => { test('parses jellyfin remote control fields and ignores legacy identity fields', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
path.join(dir, 'config.jsonc'), path.join(dir, 'config.jsonc'),
@@ -836,6 +839,7 @@ test('parses jellyfin remote control fields', () => {
"remoteControlEnabled": true, "remoteControlEnabled": true,
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true,
"autoAnnounce": true, "autoAnnounce": true,
"clientName": "Custom Client",
"remoteControlDeviceName": "SubMiner" "remoteControlDeviceName": "SubMiner"
} }
}`, }`,
@@ -850,7 +854,8 @@ test('parses jellyfin remote control fields', () => {
assert.equal(config.jellyfin.remoteControlEnabled, true); assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true); assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, true); assert.equal(config.jellyfin.autoAnnounce, true);
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner'); assert.equal('clientName' in config.jellyfin, false);
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
}); });
test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', () => { test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', () => {
@@ -2462,6 +2467,8 @@ test('template generator includes known keys', () => {
assert.match(output, /"startupWarmups":/); assert.match(output, /"startupWarmups":/);
assert.match(output, /"updates":/); assert.match(output, /"updates":/);
assert.match(output, /"youtube":/); assert.match(output, /"youtube":/);
assert.doesNotMatch(output, /"deviceId":/);
assert.doesNotMatch(output, /"clientVersion":/);
assert.doesNotMatch(output, /"youtubeSubgen":/); assert.doesNotMatch(output, /"youtubeSubgen":/);
assert.match(output, /"characterDictionary":\s*\{/); assert.match(output, /"characterDictionary":\s*\{/);
assert.match(output, /"preserveLineBreaks": false/); assert.match(output, /"preserveLineBreaks": false/);
@@ -126,14 +126,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
serverUrl: '', serverUrl: '',
recentServers: [], recentServers: [],
username: '', username: '',
deviceId: 'subminer',
clientName: 'SubMiner',
clientVersion: '0.1.0',
defaultLibraryId: '', defaultLibraryId: '',
remoteControlEnabled: true, remoteControlEnabled: true,
remoteControlAutoConnect: true, remoteControlAutoConnect: true,
autoAnnounce: false, autoAnnounce: false,
remoteControlDeviceName: 'SubMiner',
pullPictures: false, pullPictures: false,
iconCacheDir: '/tmp/subminer-jellyfin-icons', iconCacheDir: '/tmp/subminer-jellyfin-icons',
directPlayPreferred: true, directPlayPreferred: true,
+2 -27
View File
@@ -258,7 +258,8 @@ export function buildIntegrationConfigOptionRegistry(
kind: 'enum', kind: 'enum',
enumValues: ['headword', 'surface'], enumValues: ['headword', 'surface'],
defaultValue: defaultConfig.ankiConnect.knownWords.matchMode, defaultValue: defaultConfig.ankiConnect.knownWords.matchMode,
description: 'Known-word matching strategy for subtitle annotations.', description:
'Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types.',
}, },
{ {
path: 'ankiConnect.knownWords.highlightEnabled', path: 'ankiConnect.knownWords.highlightEnabled',
@@ -520,26 +521,6 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.jellyfin.username, defaultValue: defaultConfig.jellyfin.username,
description: 'Default Jellyfin username used during CLI login.', description: 'Default Jellyfin username used during CLI login.',
}, },
{
path: 'jellyfin.deviceId',
kind: 'string',
defaultValue: defaultConfig.jellyfin.deviceId,
description:
'Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.',
},
{
path: 'jellyfin.clientName',
kind: 'string',
defaultValue: defaultConfig.jellyfin.clientName,
description: 'Client name sent on the Jellyfin authentication handshake; primarily internal.',
},
{
path: 'jellyfin.clientVersion',
kind: 'string',
defaultValue: defaultConfig.jellyfin.clientVersion,
description:
'Client version sent on the Jellyfin authentication handshake; primarily internal.',
},
{ {
path: 'jellyfin.defaultLibraryId', path: 'jellyfin.defaultLibraryId',
kind: 'string', kind: 'string',
@@ -565,12 +546,6 @@ export function buildIntegrationConfigOptionRegistry(
description: description:
'When enabled, automatically trigger remote announce/visibility check on websocket connect.', 'When enabled, automatically trigger remote announce/visibility check on websocket connect.',
}, },
{
path: 'jellyfin.remoteControlDeviceName',
kind: 'string',
defaultValue: defaultConfig.jellyfin.remoteControlDeviceName,
description: 'Device name reported for Jellyfin remote control sessions.',
},
{ {
path: 'jellyfin.pullPictures', path: 'jellyfin.pullPictures',
kind: 'boolean', kind: 'boolean',
-3
View File
@@ -364,9 +364,6 @@ export function applyIntegrationConfig(context: ResolveContext): void {
const stringKeys = [ const stringKeys = [
'serverUrl', 'serverUrl',
'username', 'username',
'deviceId',
'clientName',
'clientVersion',
'defaultLibraryId', 'defaultLibraryId',
'iconCacheDir', 'iconCacheDir',
'transcodeVideoCodec', 'transcodeVideoCodec',
-4
View File
@@ -57,7 +57,6 @@ test('settings registry hides removed modal-only fields', () => {
'shortcuts.multiCopyTimeoutMs', 'shortcuts.multiCopyTimeoutMs',
'anilist.characterDictionary.profileScope', 'anilist.characterDictionary.profileScope',
'jellyfin.directPlayContainers', 'jellyfin.directPlayContainers',
'jellyfin.remoteControlDeviceName',
]) { ]) {
assert.equal( assert.equal(
fields.some((candidate) => candidate.configPath === path), fields.some((candidate) => candidate.configPath === path),
@@ -244,10 +243,7 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
'controller.preferredGamepadLabel', 'controller.preferredGamepadLabel',
'controller.profiles', 'controller.profiles',
'youtubeSubgen.whisperBin', 'youtubeSubgen.whisperBin',
'jellyfin.clientVersion',
'jellyfin.defaultLibraryId', 'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'jellyfin.clientName',
'subtitleSidebar.toggleKey', 'subtitleSidebar.toggleKey',
'jellyfin.recentServers', 'jellyfin.recentServers',
]) { ]) {
-4
View File
@@ -68,12 +68,8 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'anilist.characterDictionary.profileScope', 'anilist.characterDictionary.profileScope',
'jellyfin.accessToken', 'jellyfin.accessToken',
'jellyfin.userId', 'jellyfin.userId',
'jellyfin.clientName',
'jellyfin.clientVersion',
'jellyfin.defaultLibraryId', 'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'jellyfin.directPlayContainers', 'jellyfin.directPlayContainers',
'jellyfin.remoteControlDeviceName',
'controller.buttonIndices', 'controller.buttonIndices',
'shortcuts.multiCopyTimeoutMs', 'shortcuts.multiCopyTimeoutMs',
'subtitleSidebar.toggleKey', 'subtitleSidebar.toggleKey',
+38
View File
@@ -280,6 +280,44 @@ test('startAppLifecycle routes control socket commands through the second-instan
assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']); assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']);
}); });
test('startAppLifecycle drains queued second-instance commands when app ready runtime fails', async () => {
const handled: string[] = [];
let controlArgvHandler: ((argv: string[]) => void) | null = null;
let readyHandler: (() => Promise<void>) | null = null;
const { deps } = createDeps({
shouldStartApp: () => true,
parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }),
handleCliCommand: (args, source) => {
handled.push(`${source}:${args.start ? 'start' : 'other'}`);
},
startControlServer: (handler) => {
controlArgvHandler = handler;
},
whenReady: (handler) => {
readyHandler = handler;
},
onReady: async () => {
handled.push('ready');
throw new Error('ready failed');
},
});
startAppLifecycle(makeArgs({ background: true }), deps);
assert.ok(controlArgvHandler);
(controlArgvHandler as (argv: string[]) => void)(['--start']);
assert.deepEqual(handled, []);
assert.ok(readyHandler);
await assert.rejects((readyHandler as () => Promise<void>)(), /ready failed/);
assert.deepEqual(handled, ['ready', 'second-instance:start']);
(controlArgvHandler as (argv: string[]) => void)(['--start']);
assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']);
});
test('startAppLifecycle quits macOS config-only launch when all windows close', () => { test('startAppLifecycle quits macOS config-only launch when all windows close', () => {
let windowAllClosedHandler: (() => void) | null = null; let windowAllClosedHandler: (() => void) | null = null;
const { deps, calls } = createDeps({ const { deps, calls } = createDeps({
+3
View File
@@ -172,9 +172,12 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
} }
deps.whenReady(async () => { deps.whenReady(async () => {
try {
await deps.onReady(); await deps.onReady();
} finally {
appReadyRuntimeComplete = true; appReadyRuntimeComplete = true;
flushPendingSecondInstanceCommands(); flushPendingSecondInstanceCommands();
}
}); });
deps.onWindowAllClosed(() => { deps.onWindowAllClosed(() => {
@@ -91,6 +91,22 @@ test('buildDiscordPresenceActivity shows media title regardless of style', () =>
} }
}); });
test('buildDiscordPresenceActivity never falls back to remote stream URLs', () => {
const payload = buildDiscordPresenceActivity(baseConfig, {
...baseSnapshot,
mediaTitle: null,
mediaPath:
'http://jellyfin.local/Videos/item-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1',
});
assert.equal(payload.details, 'Unknown media');
assert.equal(payload.state, 'Playing 01:35 / 24:10');
const serialized = JSON.stringify(payload);
assert.equal(serialized.includes('api_key'), false);
assert.equal(serialized.includes('secret-token'), false);
assert.equal(serialized.includes('/Videos/item-1/stream'), false);
});
test('service deduplicates identical updates and sends changed timeline', async () => { test('service deduplicates identical updates and sends changed timeline', async () => {
const sent: DiscordActivityPayload[] = []; const sent: DiscordActivityPayload[] = [];
const timers = new Map<number, () => void>(); const timers = new Map<number, () => void>();
+13 -1
View File
@@ -106,6 +106,15 @@ function basename(filePath: string | null): string {
return parts[parts.length - 1] ?? ''; return parts[parts.length - 1] ?? '';
} }
function fallbackTitleFromMediaPath(mediaPath: string | null): string {
const trimmed = mediaPath?.trim();
if (!trimmed) return '';
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && !trimmed.toLowerCase().startsWith('file://')) {
return '';
}
return basename(trimmed).split(/[?#]/)[0] ?? '';
}
function buildStatus(snapshot: DiscordPresenceSnapshot): string { function buildStatus(snapshot: DiscordPresenceSnapshot): string {
if (!snapshot.connected || !snapshot.mediaPath) return 'Idle'; if (!snapshot.connected || !snapshot.mediaPath) return 'Idle';
if (snapshot.paused) return 'Paused'; if (snapshot.paused) return 'Paused';
@@ -130,7 +139,10 @@ export function buildDiscordPresenceActivity(
): DiscordActivityPayload { ): DiscordActivityPayload {
const style = resolvePresenceStyle(config.presenceStyle); const style = resolvePresenceStyle(config.presenceStyle);
const status = buildStatus(snapshot); const status = buildStatus(snapshot);
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media'); const title = sanitizeText(
snapshot.mediaTitle,
fallbackTitleFromMediaPath(snapshot.mediaPath) || 'Unknown media',
);
const details = const details =
snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails; snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails;
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`; const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
@@ -60,7 +60,10 @@ test('buildHyprlandPlacementDispatches floats tiled overlay windows without pinn
floating: false, floating: false,
pinned: false, pinned: false,
}), }),
[['dispatch', 'setfloating', 'address:0xabc']], [
['dispatch', 'setfloating', 'address:0xabc'],
['dispatch', 'alterzorder', 'top,address:0xabc'],
],
); );
}); });
@@ -87,6 +90,7 @@ test('buildHyprlandPlacementDispatches force-aligns floating overlay windows to
['dispatch', 'setprop', 'address:0xabc no_shadow 1'], ['dispatch', 'setprop', 'address:0xabc no_shadow 1'],
['dispatch', 'setprop', 'address:0xabc no_blur 1'], ['dispatch', 'setprop', 'address:0xabc no_blur 1'],
['dispatch', 'setprop', 'address:0xabc decorate 0'], ['dispatch', 'setprop', 'address:0xabc decorate 0'],
['dispatch', 'alterzorder', 'top,address:0xabc'],
], ],
); );
}); });
@@ -98,7 +102,7 @@ test('buildHyprlandPlacementDispatches does not pin already floating overlay win
floating: true, floating: true,
pinned: false, pinned: false,
}), }),
[], [['dispatch', 'alterzorder', 'top,address:0xabc']],
); );
}); });
@@ -109,7 +113,10 @@ test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows'
floating: true, floating: true,
pinned: true, pinned: true,
}), }),
[['dispatch', 'pin', 'address:0xabc']], [
['dispatch', 'pin', 'address:0xabc'],
['dispatch', 'alterzorder', 'top,address:0xabc'],
],
); );
}); });
@@ -146,6 +153,7 @@ test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for ma
[ [
['-j', 'clients'], ['-j', 'clients'],
['dispatch', 'setfloating', 'address:0xmatch'], ['dispatch', 'setfloating', 'address:0xmatch'],
['dispatch', 'alterzorder', 'top,address:0xmatch'],
], ],
); );
}); });
@@ -195,6 +203,7 @@ test('ensureHyprlandWindowFloatingByTitle dispatches exact Hyprland geometry whe
['dispatch', 'setprop', 'address:0xmatch no_shadow 1'], ['dispatch', 'setprop', 'address:0xmatch no_shadow 1'],
['dispatch', 'setprop', 'address:0xmatch no_blur 1'], ['dispatch', 'setprop', 'address:0xmatch no_blur 1'],
['dispatch', 'setprop', 'address:0xmatch decorate 0'], ['dispatch', 'setprop', 'address:0xmatch decorate 0'],
['dispatch', 'alterzorder', 'top,address:0xmatch'],
], ],
); );
}); });
@@ -95,6 +95,7 @@ export function buildHyprlandPlacementDispatches(
dispatches.push(['dispatch', 'setprop', `${windowAddress} no_blur 1`]); dispatches.push(['dispatch', 'setprop', `${windowAddress} no_blur 1`]);
dispatches.push(['dispatch', 'setprop', `${windowAddress} decorate 0`]); dispatches.push(['dispatch', 'setprop', `${windowAddress} decorate 0`]);
} }
dispatches.push(['dispatch', 'alterzorder', `top,${windowAddress}`]);
return dispatches; return dispatches;
} }
@@ -1552,6 +1552,98 @@ test('handleMediaChange reuses the same provisional anime row across matching fi
} }
}); });
test('Jellyfin playback metadata links stream videos to existing series title', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange('/tmp/The Beginning After the End S02E01.mkv', 'Episode 1');
await waitForPendingAnimeMetadata(tracker);
tracker.destroy();
tracker = null;
tracker = new Ctor({ dbPath });
tracker.recordJellyfinPlaybackMetadata({
mediaPath:
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1',
displayTitle: 'The Beginning After the End S02E02 The Princess Begins Adventuring',
itemTitle: 'The Princess Begins Adventuring',
seriesTitle: 'The Beginning After the End',
seasonNumber: 2,
episodeNumber: 2,
itemId: 'item-2',
});
tracker.handleMediaChange(
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1',
'The Beginning After the End S02E02 The Princess Begins Adventuring',
);
tracker.handleMediaChange(null, null);
tracker.recordJellyfinPlaybackMetadata({
mediaPath:
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000',
displayTitle: 'The Beginning After the End S02E02 The Princess Begins Adventuring',
itemTitle: 'The Princess Begins Adventuring',
seriesTitle: 'The Beginning After the End',
seasonNumber: 2,
episodeNumber: 2,
itemId: 'item-2',
});
tracker.handleMediaChange(
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000',
'The Beginning After the End S02E02 The Princess Begins Adventuring',
);
const privateApi = tracker as unknown as { db: DatabaseSync };
const rows = privateApi.db
.prepare(
`
SELECT
v.source_url,
v.canonical_title AS video_title,
v.parsed_title,
v.parsed_season,
v.parsed_episode,
v.parser_source,
a.canonical_title AS anime_title
FROM imm_videos v
JOIN imm_anime a ON a.anime_id = v.anime_id
ORDER BY v.video_id
`,
)
.all() as Array<{
source_url: string | null;
video_title: string;
parsed_title: string | null;
parsed_season: number | null;
parsed_episode: number | null;
parser_source: string | null;
anime_title: string;
}>;
assert.equal(rows.length, 2);
assert.equal(new Set(rows.map((row) => row.anime_title)).size, 1);
const jellyfinRow = rows.find(
(row) => row.source_url === 'jellyfin://jellyfin.local/item/item-2',
);
assert.ok(jellyfinRow);
assert.equal(
jellyfinRow.video_title,
'The Beginning After the End S02E02 The Princess Begins Adventuring',
);
assert.equal(jellyfinRow.parsed_title, 'The Beginning After the End');
assert.equal(jellyfinRow.parsed_season, 2);
assert.equal(jellyfinRow.parsed_episode, 2);
assert.equal(jellyfinRow.parser_source, 'jellyfin');
assert.equal(jellyfinRow.anime_title, 'The Beginning After the End');
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('applies configurable queue, flush, and retention policy', async () => { test('applies configurable queue, flush, and retention policy', async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null; let tracker: ImmersionTrackerService | null = null;
+107 -2
View File
@@ -301,6 +301,33 @@ export type {
VocabularyStatsRow, VocabularyStatsRow,
} from './immersion-tracker/types'; } from './immersion-tracker/types';
export interface JellyfinPlaybackMetadataInput {
mediaPath: string;
displayTitle: string;
itemTitle: string;
seriesTitle: string | null;
seasonNumber: number | null;
episodeNumber: number | null;
itemId: string;
}
function normalizeMetadataInt(value: number | null | undefined): number | null {
return typeof value === 'number' && Number.isSafeInteger(value) ? value : null;
}
function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string {
const normalizedItemId = normalizeText(itemId);
if (!normalizedItemId) {
return mediaPath;
}
try {
const parsed = new URL(mediaPath);
return `jellyfin://${parsed.host}/item/${encodeURIComponent(normalizedItemId)}`;
} catch {
return `jellyfin://item/${encodeURIComponent(normalizedItemId)}`;
}
}
export class ImmersionTrackerService { export class ImmersionTrackerService {
private readonly logger = createLogger('main:immersion-tracker'); private readonly logger = createLogger('main:immersion-tracker');
private readonly db: DatabaseSync; private readonly db: DatabaseSync;
@@ -337,6 +364,7 @@ export class ImmersionTrackerService {
private readonly pendingYoutubeMetadataFetches = new Map<number, Promise<void>>(); private readonly pendingYoutubeMetadataFetches = new Map<number, Promise<void>>();
private readonly recordedSubtitleKeys = new Set<string>(); private readonly recordedSubtitleKeys = new Set<string>();
private readonly pendingAnimeMetadataUpdates = new Map<number, Promise<void>>(); private readonly pendingAnimeMetadataUpdates = new Map<number, Promise<void>>();
private readonly mediaPathAliases = new Map<string, string>();
private readonly resolveLegacyVocabularyPos: private readonly resolveLegacyVocabularyPos:
| ((row: LegacyVocabularyPosRow) => Promise<LegacyVocabularyPosResolution | null>) | ((row: LegacyVocabularyPosRow) => Promise<LegacyVocabularyPosResolution | null>)
| undefined; | undefined;
@@ -1115,8 +1143,85 @@ export class ImmersionTrackerService {
rebuildLifetimeSummaryTables(this.db); rebuildLifetimeSummaryTables(this.db);
} }
recordJellyfinPlaybackMetadata(metadata: JellyfinPlaybackMetadataInput): void {
const rawPath = normalizeMediaPath(metadata.mediaPath);
if (!rawPath) {
return;
}
const normalizedPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
this.mediaPathAliases.set(rawPath, normalizedPath);
const displayTitle =
normalizeText(metadata.displayTitle) ||
normalizeText(metadata.itemTitle) ||
deriveCanonicalTitle(normalizedPath);
const itemTitle = normalizeText(metadata.itemTitle) || displayTitle;
const seriesTitle = normalizeText(metadata.seriesTitle);
const libraryTitle = seriesTitle || itemTitle;
if (!libraryTitle) {
return;
}
const videoId = getOrCreateVideoRecord(
this.db,
buildVideoKey(normalizedPath, SOURCE_TYPE_REMOTE),
{
canonicalTitle: displayTitle,
sourcePath: null,
sourceUrl: normalizedPath,
sourceType: SOURCE_TYPE_REMOTE,
},
);
const previousLink = this.db
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
.get(videoId) as { animeId: number | null } | null;
const metadataJson = JSON.stringify({
source: 'jellyfin',
itemId: normalizeText(metadata.itemId) || null,
itemTitle,
seriesTitle: seriesTitle || null,
displayTitle,
seasonNumber: normalizeMetadataInt(metadata.seasonNumber),
episodeNumber: normalizeMetadataInt(metadata.episodeNumber),
});
const animeId = getOrCreateAnimeRecord(this.db, {
parsedTitle: libraryTitle,
canonicalTitle: libraryTitle,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson,
});
linkVideoToAnimeRecord(this.db, videoId, {
animeId,
parsedBasename: null,
parsedTitle: libraryTitle,
parsedSeason: normalizeMetadataInt(metadata.seasonNumber),
parsedEpisode: normalizeMetadataInt(metadata.episodeNumber),
parserSource: 'jellyfin',
parserConfidence: 1,
parseMetadataJson: metadataJson,
});
const hasLifetimeMedia = Boolean(
this.db.prepare('SELECT 1 FROM imm_lifetime_media WHERE video_id = ?').get(videoId),
);
if (hasLifetimeMedia || (previousLink && previousLink.animeId !== animeId)) {
rebuildLifetimeSummaryTables(this.db);
}
}
private hasJellyfinMetadata(videoId: number): boolean {
const row = this.db
.prepare('SELECT parser_source AS parserSource FROM imm_videos WHERE video_id = ?')
.get(videoId) as { parserSource: string | null } | null;
return row?.parserSource === 'jellyfin';
}
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void { handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
const normalizedPath = normalizeMediaPath(mediaPath); const rawPath = normalizeMediaPath(mediaPath);
const normalizedPath = this.mediaPathAliases.get(rawPath) ?? rawPath;
const normalizedTitle = normalizeText(mediaTitle); const normalizedTitle = normalizeText(mediaTitle);
this.logger.info( this.logger.info(
`handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`, `handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`,
@@ -1164,7 +1269,7 @@ export class ImmersionTrackerService {
if (youtubeVideoId) { if (youtubeVideoId) {
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId); void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath); this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
} else { } else if (!this.hasJellyfinMetadata(sessionInfo.videoId)) {
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null); this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
} }
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath); this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
+38
View File
@@ -289,6 +289,44 @@ test('reportProgress posts timeline payload and treats failure as non-fatal', as
assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload); assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload);
}); });
test('timeline payload omits websocket-only event names', () => {
const payload = buildJellyfinTimelinePayload({
itemId: 'movie-2',
positionTicks: 123456,
eventName: 'TimeUpdate',
});
assert.equal('EventName' in payload, false);
});
test('reportStopped posts final position and explicit non-failed state', async () => {
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
const service = new JellyfinRemoteSessionService({
serverUrl: 'http://jellyfin.local',
accessToken: 'token-stop-payload',
deviceId: 'device-stop-payload',
webSocketFactory: () => new FakeWebSocket() as unknown as any,
fetchImpl: (async (input, init) => {
fetchCalls.push({ input: String(input), init: init ?? {} });
return new Response(null, { status: 200 });
}) as typeof fetch,
});
const ok = await service.reportStopped({
itemId: 'movie-stop',
positionTicks: 7654321,
failed: false,
});
const stoppedCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Stopped'));
assert.equal(ok, true);
assert.ok(stoppedCall);
assert.ok(typeof stoppedCall.init.body === 'string');
const posted = JSON.parse(String(stoppedCall.init.body));
assert.equal(posted.PositionTicks, 7654321);
assert.equal(posted.Failed, false);
});
test('advertiseNow validates server registration using Sessions endpoint', async () => { test('advertiseNow validates server registration using Sessions endpoint', async () => {
const sockets: FakeWebSocket[] = []; const sockets: FakeWebSocket[] = [];
const calls: string[] = []; const calls: string[] = [];
+5 -7
View File
@@ -20,6 +20,7 @@ export interface JellyfinTimelinePlaybackState {
subtitleStreamIndex?: number | null; subtitleStreamIndex?: number | null;
playlistItemId?: string | null; playlistItemId?: string | null;
eventName?: string; eventName?: string;
failed?: boolean;
} }
export interface JellyfinTimelinePayload { export interface JellyfinTimelinePayload {
@@ -36,7 +37,7 @@ export interface JellyfinTimelinePayload {
AudioStreamIndex?: number | null; AudioStreamIndex?: number | null;
SubtitleStreamIndex?: number | null; SubtitleStreamIndex?: number | null;
PlaylistItemId?: string | null; PlaylistItemId?: string | null;
EventName: string; Failed?: boolean;
} }
interface JellyfinRemoteSocket { interface JellyfinRemoteSocket {
@@ -168,7 +169,7 @@ export function buildJellyfinTimelinePayload(
AudioStreamIndex: asNullableInteger(state.audioStreamIndex), AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex), SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
PlaylistItemId: state.playlistItemId, PlaylistItemId: state.playlistItemId,
EventName: state.eventName || 'timeupdate', Failed: state.failed,
}; };
} }
@@ -269,10 +270,7 @@ export class JellyfinRemoteSessionService {
} }
public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> { public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
return this.postTimeline('/Sessions/Playing', { return this.postTimeline('/Sessions/Playing', buildJellyfinTimelinePayload(state));
...buildJellyfinTimelinePayload(state),
EventName: state.eventName || 'start',
});
} }
public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> { public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
@@ -282,7 +280,7 @@ export class JellyfinRemoteSessionService {
public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> { public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
return this.postTimeline('/Sessions/Playing/Stopped', { return this.postTimeline('/Sessions/Playing/Stopped', {
...buildJellyfinTimelinePayload(state), ...buildJellyfinTimelinePayload(state),
EventName: state.eventName || 'stop', Failed: state.failed === true,
}); });
} }
+4
View File
@@ -560,6 +560,10 @@ test('resolvePlaybackPlan preserves episode metadata, stream selection, and resu
assert.equal(plan.mode, 'direct'); assert.equal(plan.mode, 'direct');
assert.equal(plan.title, 'Galaxy Quest S02E07 A New Hope'); assert.equal(plan.title, 'Galaxy Quest S02E07 A New Hope');
assert.equal(plan.itemTitle, 'A New Hope');
assert.equal(plan.seriesTitle, 'Galaxy Quest');
assert.equal(plan.seasonNumber, 2);
assert.equal(plan.episodeNumber, 7);
assert.equal(plan.audioStreamIndex, 6); assert.equal(plan.audioStreamIndex, 6);
assert.equal(plan.subtitleStreamIndex, 9); assert.equal(plan.subtitleStreamIndex, 9);
assert.equal(plan.startTimeTicks, 35_000_000); assert.equal(plan.startTimeTicks, 35_000_000);
+23 -3
View File
@@ -27,6 +27,10 @@ export interface JellyfinPlaybackPlan {
mode: 'direct' | 'transcode'; mode: 'direct' | 'transcode';
url: string; url: string;
title: string; title: string;
itemTitle: string;
seriesTitle: string | null;
seasonNumber: number | null;
episodeNumber: number | null;
startTimeTicks: number; startTimeTicks: number;
audioStreamIndex: number | null; audioStreamIndex: number | null;
subtitleStreamIndex: number | null; subtitleStreamIndex: number | null;
@@ -292,14 +296,24 @@ function getStreamDefaults(source: JellyfinMediaSource): {
}; };
} }
function getItemTitle(item: JellyfinItem): string {
return ensureString(item.Name).trim() || 'Jellyfin Item';
}
function getSeriesTitle(item: JellyfinItem): string | null {
return ensureString(item.SeriesName).trim() || null;
}
function getDisplayTitle(item: JellyfinItem): string { function getDisplayTitle(item: JellyfinItem): string {
const itemTitle = getItemTitle(item);
if (item.Type === 'Episode') { if (item.Type === 'Episode') {
const season = asIntegerOrNull(item.ParentIndexNumber) ?? 0; const season = asIntegerOrNull(item.ParentIndexNumber) ?? 0;
const episode = asIntegerOrNull(item.IndexNumber) ?? 0; const episode = asIntegerOrNull(item.IndexNumber) ?? 0;
const prefix = item.SeriesName ? `${item.SeriesName} ` : ''; const seriesTitle = getSeriesTitle(item);
return `${prefix}S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} ${ensureString(item.Name).trim()}`.trim(); const prefix = seriesTitle ? `${seriesTitle} ` : '';
return `${prefix}S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} ${itemTitle}`.trim();
} }
return ensureString(item.Name).trim() || 'Jellyfin Item'; return itemTitle;
} }
function shouldPreferDirectPlay(source: JellyfinMediaSource, config: JellyfinConfig): boolean { function shouldPreferDirectPlay(source: JellyfinMediaSource, config: JellyfinConfig): boolean {
@@ -521,10 +535,16 @@ export async function resolvePlaybackPlan(
const audioStreamIndex = selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null; const audioStreamIndex = selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null;
const subtitleStreamIndex = selection.subtitleStreamIndex ?? null; const subtitleStreamIndex = selection.subtitleStreamIndex ?? null;
const startTimeTicks = Math.max(0, asIntegerOrNull(item.UserData?.PlaybackPositionTicks) ?? 0); const startTimeTicks = Math.max(0, asIntegerOrNull(item.UserData?.PlaybackPositionTicks) ?? 0);
const itemTitle = getItemTitle(item);
const seriesTitle = item.Type === 'Episode' ? getSeriesTitle(item) : null;
const basePlan: JellyfinPlaybackPlan = { const basePlan: JellyfinPlaybackPlan = {
mode: 'transcode', mode: 'transcode',
url: '', url: '',
title: getDisplayTitle(item), title: getDisplayTitle(item),
itemTitle,
seriesTitle,
seasonNumber: item.Type === 'Episode' ? asIntegerOrNull(item.ParentIndexNumber) : null,
episodeNumber: item.Type === 'Episode' ? asIntegerOrNull(item.IndexNumber) : null,
startTimeTicks, startTimeTicks,
audioStreamIndex, audioStreamIndex,
subtitleStreamIndex, subtitleStreamIndex,
+138 -5
View File
@@ -197,7 +197,54 @@ test('tracked non-macOS overlay stays hidden while tracker is not ready', () =>
assert.ok(!calls.includes('osd')); assert.ok(!calls.includes('osd'));
}); });
test('untracked non-macOS overlay keeps fallback visible behavior when no tracker exists', () => { test('suspended visible overlay hides without refreshing bounds or z-order', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
suspendVisibleOverlay: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('update-bounds'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
test('untracked non-macOS overlay shows passively when no tracker exists', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
let trackerWarning = false; let trackerWarning = false;
@@ -232,11 +279,49 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke
} as never); } as never);
assert.equal(trackerWarning, false); assert.equal(trackerWarning, false);
assert.ok(calls.includes('show')); assert.ok(calls.includes('show-inactive'));
assert.ok(calls.includes('focus')); assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
assert.ok(!calls.includes('osd')); assert.ok(!calls.includes('osd'));
}); });
test('passive Linux visible overlay does not take keyboard focus', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
test('tracked non-macOS overlay reapplies bounds after first show', () => { test('tracked non-macOS overlay reapplies bounds after first show', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {
@@ -270,8 +355,8 @@ test('tracked non-macOS overlay reapplies bounds after first show', () => {
} as never); } as never);
assert.deepEqual( assert.deepEqual(
calls.filter((call) => call === 'update-bounds' || call === 'show'), calls.filter((call) => call === 'update-bounds' || call === 'show-inactive'),
['update-bounds', 'show', 'update-bounds'], ['update-bounds', 'show-inactive', 'update-bounds'],
); );
}); });
@@ -1213,6 +1298,54 @@ test('macOS tracked overlay hides when mpv loses foreground', () => {
assert.ok(!calls.includes('show')); assert.ok(!calls.includes('show'));
}); });
test('macOS keeps visible overlay stable while probing frontmost app after overlay blur', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
macOSForegroundProbeActive: true,
} as never);
assert.ok(calls.includes('update-bounds'));
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('hide'));
});
test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => { test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => {
const { window, calls, setFocused } = createMainWindowRecorder(); const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {
+36 -3
View File
@@ -64,12 +64,14 @@ export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean; visibleOverlayVisible: boolean;
modalActive?: boolean; modalActive?: boolean;
forceMousePassthrough?: boolean; forceMousePassthrough?: boolean;
suspendVisibleOverlay?: boolean;
overlayInteractionActive?: boolean; overlayInteractionActive?: boolean;
mainWindow: BrowserWindow | null; mainWindow: BrowserWindow | null;
windowTracker: BaseWindowTracker | null; windowTracker: BaseWindowTracker | null;
lastKnownWindowsForegroundProcessName?: string | null; lastKnownWindowsForegroundProcessName?: string | null;
windowsOverlayProcessName?: string | null; windowsOverlayProcessName?: string | null;
windowsFocusHandoffGraceActive?: boolean; windowsFocusHandoffGraceActive?: boolean;
macOSForegroundProbeActive?: boolean;
trackerNotReadyWarningShown: boolean; trackerNotReadyWarningShown: boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void; setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
@@ -103,6 +105,18 @@ export function updateVisibleOverlayVisibility(args: {
return; return;
} }
if (args.suspendVisibleOverlay) {
if (args.isWindowsPlatform) {
clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.setIgnoreMouseEvents(true, { forward: true });
releaseOverlayWindowLevel(mainWindow);
mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
const showPassiveVisibleOverlay = (): boolean => { const showPassiveVisibleOverlay = (): boolean => {
const forceMousePassthrough = args.forceMousePassthrough === true; const forceMousePassthrough = args.forceMousePassthrough === true;
const wasVisible = mainWindow.isVisible(); const wasVisible = mainWindow.isVisible();
@@ -115,6 +129,12 @@ export function updateVisibleOverlayVisibility(args: {
const isTrackedMacOSTargetMinimized = const isTrackedMacOSTargetMinimized =
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true; canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.(); const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.();
const shouldPreserveMacOSOverlayDuringForegroundProbe =
args.isMacOSPlatform &&
args.macOSForegroundProbeActive === true &&
!!windowTracker &&
!isTrackedMacOSTargetMinimized &&
(windowTracker.isTracking() || windowTracker.getGeometry() !== null);
const hasTransientMacOSTrackerLoss = const hasTransientMacOSTrackerLoss =
args.isMacOSPlatform && args.isMacOSPlatform &&
canReportMacOSTargetMinimized && canReportMacOSTargetMinimized &&
@@ -124,7 +144,10 @@ export function updateVisibleOverlayVisibility(args: {
trackedMacOSTargetFocused !== false && trackedMacOSTargetFocused !== false &&
mainWindow.isVisible(); mainWindow.isVisible();
const isTrackedMacOSTargetFocused = const isTrackedMacOSTargetFocused =
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker hasTransientMacOSTrackerLoss ||
shouldPreserveMacOSOverlayDuringForegroundProbe ||
!args.isMacOSPlatform ||
!args.windowTracker
? true ? true
: (trackedMacOSTargetFocused ?? true); : (trackedMacOSTargetFocused ?? true);
const shouldReleaseMacOSOverlayLevel = const shouldReleaseMacOSOverlayLevel =
@@ -162,6 +185,8 @@ export function updateVisibleOverlayVisibility(args: {
shouldUseMacOSMousePassthrough || shouldUseMacOSMousePassthrough ||
forceMousePassthrough || forceMousePassthrough ||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow)); (shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
const isNonNativePassiveOverlay =
!args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive;
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker; const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
const shouldKeepTrackedWindowsOverlayTopmost = const shouldKeepTrackedWindowsOverlayTopmost =
!args.isWindowsPlatform || !args.isWindowsPlatform ||
@@ -204,7 +229,10 @@ export function updateVisibleOverlayVisibility(args: {
// skip — ready-to-show hasn't fired yet; the onWindowContentReady // skip — ready-to-show hasn't fired yet; the onWindowContentReady
// callback will trigger another visibility update when the renderer // callback will trigger another visibility update when the renderer
// has painted its first frame. // has painted its first frame.
} else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) { } else if (
((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) ||
isNonNativePassiveOverlay
) {
if (args.isWindowsPlatform) { if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0); setOverlayWindowOpacity(mainWindow, 0);
} }
@@ -248,7 +276,12 @@ export function updateVisibleOverlayVisibility(args: {
mainWindow.focus(); mainWindow.focus();
} }
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) { if (
!args.isWindowsPlatform &&
!args.isMacOSPlatform &&
!forceMousePassthrough &&
overlayInteractionActive
) {
mainWindow.focus(); mainWindow.focus();
} }
+67 -1
View File
@@ -1,4 +1,8 @@
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'; import type {
BrowserWindow,
BrowserWindowConstructorOptions,
MessageBoxSyncOptions,
} from 'electron';
import type { WindowGeometry } from '../../types'; import type { WindowGeometry } from '../../types';
const DEFAULT_STATS_WINDOW_WIDTH = 900; const DEFAULT_STATS_WINDOW_WIDTH = 900;
@@ -7,6 +11,17 @@ export const STATS_WINDOW_TITLE = 'SubMiner Stats';
type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTop'> & type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTop'> &
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>; Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
type VisibleStatsWindowLevelController = StatsWindowLevelController &
Pick<BrowserWindow, 'isDestroyed' | 'isVisible'>;
type VisibleStatsWindowDialogLayerController = Pick<
BrowserWindow,
'isDestroyed' | 'isVisible' | 'setAlwaysOnTop'
>;
type StatsNativeConfirmDialogWindow = Pick<BrowserWindow, 'isDestroyed'>;
type StatsNativeConfirmDialogPresenter<WindowT> = {
showWithParent: (window: WindowT, options: MessageBoxSyncOptions) => number;
showWithoutParent: (options: MessageBoxSyncOptions) => number;
};
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>; type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> & type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
@@ -106,6 +121,57 @@ export function promoteStatsWindowLevel(
window.moveTop(); window.moveTop();
} }
export function promoteVisibleStatsWindowAboveOverlay(
window: VisibleStatsWindowLevelController,
options: {
platform?: NodeJS.Platform;
promoteHyprlandWindow?: () => void;
} = {},
): boolean {
if (window.isDestroyed() || !window.isVisible()) {
return false;
}
promoteStatsWindowLevel(window, options.platform);
options.promoteHyprlandWindow?.();
return true;
}
export function demoteVisibleStatsWindowBelowDialogs(
window: VisibleStatsWindowDialogLayerController,
): boolean {
if (window.isDestroyed() || !window.isVisible()) {
return false;
}
window.setAlwaysOnTop(false);
return true;
}
export function buildStatsNativeConfirmDialogOptions(message: string): MessageBoxSyncOptions {
return {
type: 'warning',
message,
buttons: ['Delete', 'Cancel'],
defaultId: 1,
cancelId: 1,
noLink: true,
};
}
export function showStatsNativeConfirmDialog<WindowT extends StatsNativeConfirmDialogWindow>(
window: WindowT | null,
message: string,
presenter: StatsNativeConfirmDialogPresenter<WindowT>,
): boolean {
const options = buildStatsNativeConfirmDialogOptions(message);
const response =
window && !window.isDestroyed()
? presenter.showWithParent(window, options)
: presenter.showWithoutParent(options);
return response === 0;
}
export function presentStatsWindow( export function presentStatsWindow(
window: StatsWindowPresentationController, window: StatsWindowPresentationController,
platform: NodeJS.Platform = process.platform, platform: NodeJS.Platform = process.platform,
+129
View File
@@ -3,9 +3,13 @@ import test from 'node:test';
import { import {
buildStatsWindowLoadFileOptions, buildStatsWindowLoadFileOptions,
buildStatsWindowOptions, buildStatsWindowOptions,
buildStatsNativeConfirmDialogOptions,
demoteVisibleStatsWindowBelowDialogs,
presentStatsWindow, presentStatsWindow,
promoteVisibleStatsWindowAboveOverlay,
promoteStatsWindowLevel, promoteStatsWindowLevel,
resolveStatsWindowOuterBoundsForContent, resolveStatsWindowOuterBoundsForContent,
showStatsNativeConfirmDialog,
shouldHideStatsWindowForInput, shouldHideStatsWindowForInput,
} from './stats-window-runtime'; } from './stats-window-runtime';
@@ -232,6 +236,131 @@ test('promoteStatsWindowLevel raises stats above overlay level on Windows', () =
assert.deepEqual(calls, ['always-on-top:true:screen-saver:2', 'move-top']); assert.deepEqual(calls, ['always-on-top:true:screen-saver:2', 'move-top']);
}); });
test('promoteVisibleStatsWindowAboveOverlay reasserts stats above overlay on Hyprland', () => {
const calls: string[] = [];
const promoted = promoteVisibleStatsWindowAboveOverlay(
{
isDestroyed: () => false,
isVisible: () => true,
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
calls.push(`always-on-top:${flag}:${level ?? 'none'}:${relativeLevel ?? 0}`);
},
moveTop: () => {
calls.push('move-top');
},
} as never,
{
platform: 'linux',
promoteHyprlandWindow: () => calls.push('hyprland-top'),
},
);
assert.equal(promoted, true);
assert.deepEqual(calls, ['always-on-top:true:none:0', 'move-top', 'hyprland-top']);
});
test('promoteVisibleStatsWindowAboveOverlay skips hidden stats windows', () => {
const calls: string[] = [];
const promoted = promoteVisibleStatsWindowAboveOverlay(
{
isDestroyed: () => false,
isVisible: () => false,
setAlwaysOnTop: () => calls.push('always-on-top'),
moveTop: () => calls.push('move-top'),
} as never,
{
promoteHyprlandWindow: () => calls.push('hyprland-top'),
},
);
assert.equal(promoted, false);
assert.deepEqual(calls, []);
});
test('demoteVisibleStatsWindowBelowDialogs lowers visible stats below native dialogs', () => {
const calls: string[] = [];
const demoted = demoteVisibleStatsWindowBelowDialogs({
isDestroyed: () => false,
isVisible: () => true,
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
calls.push(`always-on-top:${flag}:${level ?? 'none'}:${relativeLevel ?? 0}`);
},
} as never);
assert.equal(demoted, true);
assert.deepEqual(calls, ['always-on-top:false:none:0']);
});
test('demoteVisibleStatsWindowBelowDialogs skips hidden stats windows', () => {
const calls: string[] = [];
const demoted = demoteVisibleStatsWindowBelowDialogs({
isDestroyed: () => false,
isVisible: () => false,
setAlwaysOnTop: () => calls.push('always-on-top'),
} as never);
assert.equal(demoted, false);
assert.deepEqual(calls, []);
});
test('buildStatsNativeConfirmDialogOptions makes delete the explicit destructive action', () => {
assert.deepEqual(buildStatsNativeConfirmDialogOptions('Delete this session?'), {
type: 'warning',
message: 'Delete this session?',
buttons: ['Delete', 'Cancel'],
defaultId: 1,
cancelId: 1,
noLink: true,
});
});
test('showStatsNativeConfirmDialog parents the native dialog to live stats windows', () => {
const calls: string[] = [];
const parent = { isDestroyed: () => false };
const confirmed = showStatsNativeConfirmDialog(parent, 'Delete this session?', {
showWithParent: (window, options) => {
assert.equal(window, parent);
calls.push(`${options.message}:${options.defaultId}:${options.cancelId}`);
return 0;
},
showWithoutParent: () => {
calls.push('unparented');
return 1;
},
});
assert.equal(confirmed, true);
assert.deepEqual(calls, ['Delete this session?:1:1']);
});
test('showStatsNativeConfirmDialog treats cancel as not confirmed', () => {
const confirmed = showStatsNativeConfirmDialog({ isDestroyed: () => false }, 'Delete?', {
showWithParent: () => 1,
showWithoutParent: () => 0,
});
assert.equal(confirmed, false);
});
test('showStatsNativeConfirmDialog falls back to an unparented dialog without a live stats window', () => {
const calls: string[] = [];
const confirmed = showStatsNativeConfirmDialog({ isDestroyed: () => true }, 'Delete?', {
showWithParent: () => {
calls.push('parented');
return 0;
},
showWithoutParent: (options) => {
calls.push(options.message);
return 0;
},
});
assert.equal(confirmed, true);
assert.deepEqual(calls, ['Delete?']);
});
test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => { test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => {
const calls: string[] = []; const calls: string[] = [];
+90 -3
View File
@@ -1,13 +1,16 @@
import { BrowserWindow, ipcMain } from 'electron'; import { BrowserWindow, dialog, ipcMain } from 'electron';
import * as path from 'path'; import * as path from 'path';
import type { WindowGeometry } from '../../types.js'; import type { WindowGeometry } from '../../types.js';
import { IPC_CHANNELS } from '../../shared/ipc/contracts.js'; import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
import { import {
buildStatsWindowLoadFileOptions, buildStatsWindowLoadFileOptions,
buildStatsWindowOptions, buildStatsWindowOptions,
demoteVisibleStatsWindowBelowDialogs,
presentStatsWindow, presentStatsWindow,
promoteStatsWindowLevel, promoteStatsWindowLevel,
promoteVisibleStatsWindowAboveOverlay,
resolveStatsWindowOuterBoundsForContent, resolveStatsWindowOuterBoundsForContent,
showStatsNativeConfirmDialog,
shouldHideStatsWindowForInput, shouldHideStatsWindowForInput,
STATS_WINDOW_TITLE, STATS_WINDOW_TITLE,
} from './stats-window-runtime.js'; } from './stats-window-runtime.js';
@@ -15,6 +18,8 @@ import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement
let statsWindow: BrowserWindow | null = null; let statsWindow: BrowserWindow | null = null;
let toggleRegistered = false; let toggleRegistered = false;
let nativeDialogLayerRegistered = false;
let nativeDialogLayerSuspensionCount = 0;
export interface StatsWindowOptions { export interface StatsWindowOptions {
/** Absolute path to stats/dist/ directory */ /** Absolute path to stats/dist/ directory */
@@ -58,7 +63,88 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds; placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
} }
options.onVisibilityChanged?.(true); options.onVisibilityChanged?.(true);
promoteStatsWindowLevel(window); promoteStatsOverlayAbovePlayback();
}
export function promoteStatsOverlayAbovePlayback(): boolean {
if (nativeDialogLayerSuspensionCount > 0) {
return false;
}
if (!statsWindow) {
return false;
}
return promoteVisibleStatsWindowAboveOverlay(statsWindow, {
promoteHyprlandWindow: () => {
ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE });
},
});
}
export function demoteStatsOverlayBelowDialogs(): boolean {
if (!statsWindow) {
return false;
}
return demoteVisibleStatsWindowBelowDialogs(statsWindow);
}
export function suspendStatsWindowLayerForNativeDialog(): void {
nativeDialogLayerSuspensionCount += 1;
if (nativeDialogLayerSuspensionCount !== 1) {
return;
}
demoteStatsOverlayBelowDialogs();
}
export function restoreStatsWindowLayerAfterNativeDialog(): void {
if (nativeDialogLayerSuspensionCount <= 0) {
return;
}
nativeDialogLayerSuspensionCount -= 1;
if (nativeDialogLayerSuspensionCount === 0) {
promoteStatsOverlayAbovePlayback();
}
}
export async function withStatsWindowLayerSuspendedForNativeDialog<T>(
showDialog: () => Promise<T>,
): Promise<T> {
suspendStatsWindowLayerForNativeDialog();
try {
return await showDialog();
} finally {
restoreStatsWindowLayerAfterNativeDialog();
}
}
function confirmStatsNativeDialog(message: unknown): boolean {
const dialogMessage =
typeof message === 'string' && message.trim().length > 0 ? message : 'Confirm deletion?';
return showStatsNativeConfirmDialog(statsWindow, dialogMessage, {
showWithParent: (parentWindow, options) => dialog.showMessageBoxSync(parentWindow, options),
showWithoutParent: (options) => dialog.showMessageBoxSync(options),
});
}
function registerStatsNativeDialogLayerHandlers(): void {
if (nativeDialogLayerRegistered) return;
nativeDialogLayerRegistered = true;
ipcMain.on(IPC_CHANNELS.command.statsNativeConfirmDialog, (event, message) => {
event.returnValue = confirmStatsNativeDialog(message);
});
ipcMain.on(IPC_CHANNELS.command.statsNativeDialogOpened, (event) => {
suspendStatsWindowLayerForNativeDialog();
event.returnValue = true;
});
ipcMain.on(IPC_CHANNELS.command.statsNativeDialogClosed, () => {
restoreStatsWindowLayerAfterNativeDialog();
});
} }
/** /**
@@ -104,7 +190,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
if (!statsWindow || statsWindow.isDestroyed() || !statsWindow.isVisible()) { if (!statsWindow || statsWindow.isDestroyed() || !statsWindow.isVisible()) {
return; return;
} }
promoteStatsWindowLevel(statsWindow); promoteStatsOverlayAbovePlayback();
}); });
} else if (statsWindow.isVisible()) { } else if (statsWindow.isVisible()) {
statsWindow.hide(); statsWindow.hide();
@@ -119,6 +205,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
* Call this once during app initialization. * Call this once during app initialization.
*/ */
export function registerStatsOverlayToggle(options: StatsWindowOptions): void { export function registerStatsOverlayToggle(options: StatsWindowOptions): void {
registerStatsNativeDialogLayerHandlers();
if (toggleRegistered) return; if (toggleRegistered) return;
toggleRegistered = true; toggleRegistered = true;
ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => { ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => {
+41
View File
@@ -70,12 +70,14 @@ test('triggerSubsyncFromConfig returns early when already in progress', async ()
test('triggerSubsyncFromConfig opens manual picker', async () => { test('triggerSubsyncFromConfig opens manual picker', async () => {
const osd: string[] = []; const osd: string[] = [];
let payloadTrackCount = 0; let payloadTrackCount = 0;
let ffsubsyncAvailable: boolean | null = null;
let inProgressState: boolean | null = null; let inProgressState: boolean | null = null;
await triggerSubsyncFromConfig( await triggerSubsyncFromConfig(
makeDeps({ makeDeps({
openManualPicker: (payload) => { openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length; payloadTrackCount = payload.sourceTracks.length;
ffsubsyncAvailable = payload.ffsubsyncAvailable;
}, },
showMpvOsd: (text) => { showMpvOsd: (text) => {
osd.push(text); osd.push(text);
@@ -87,10 +89,49 @@ test('triggerSubsyncFromConfig opens manual picker', async () => {
); );
assert.equal(payloadTrackCount, 1); assert.equal(payloadTrackCount, 1);
assert.equal(ffsubsyncAvailable, true);
assert.ok(osd.includes('Subsync: choose engine and source')); assert.ok(osd.includes('Subsync: choose engine and source'));
assert.equal(inProgressState, false); assert.equal(inProgressState, false);
}); });
test('triggerSubsyncFromConfig marks ffsubsync unavailable for remote media paths', async () => {
let ffsubsyncAvailable: boolean | null = null;
await triggerSubsyncFromConfig(
makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: () => {},
requestProperty: async (name: string) => {
if (name === 'path') return 'https://jellyfin.example/Videos/movie/stream.mkv';
if (name === 'sid') return 1;
if (name === 'secondary-sid') return null;
if (name === 'track-list') {
return [
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
{
id: 2,
type: 'sub',
selected: false,
external: true,
lang: 'eng',
'external-filename': 'https://jellyfin.example/subs/eng.srt',
},
];
}
return null;
},
}),
openManualPicker: (payload) => {
ffsubsyncAvailable = payload.ffsubsyncAvailable;
},
}),
);
assert.equal(ffsubsyncAvailable, false);
});
test('triggerSubsyncFromConfig does not run automatic sync', async () => { test('triggerSubsyncFromConfig does not run automatic sync', async () => {
const osd: string[] = []; const osd: string[] = [];
let payloadTrackCount = 0; let payloadTrackCount = 0;
+1
View File
@@ -378,6 +378,7 @@ export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps
const client = getMpvClientForSubsync(deps); const client = getMpvClientForSubsync(deps);
const context = await gatherSubsyncContext(client); const context = await gatherSubsyncContext(client);
const payload: SubsyncManualPayload = { const payload: SubsyncManualPayload = {
ffsubsyncAvailable: !isRemoteMediaPath(context.videoPath),
sourceTracks: context.sourceTracks sourceTracks: context.sourceTracks
.filter((track) => typeof track.id === 'number') .filter((track) => typeof track.id === 'number')
.map((track) => ({ .map((track) => ({
+10 -10
View File
@@ -129,7 +129,7 @@ test('tokenizeSubtitle splits same-line grammar endings before applying annotati
assert.equal(result.tokens?.[0]?.jlptLevel, 'N5'); assert.equal(result.tokens?.[0]?.jlptLevel, 'N5');
assert.equal(result.tokens?.[0]?.frequencyRank, 40); assert.equal(result.tokens?.[0]?.frequencyRank, 40);
assert.equal(result.tokens?.[1]?.surface, 'です'); assert.equal(result.tokens?.[1]?.surface, 'です');
assert.equal(result.tokens?.[1]?.isKnown, false); assert.equal(result.tokens?.[1]?.isKnown, true);
assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false); assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false);
assert.equal(result.tokens?.[1]?.frequencyRank, undefined); assert.equal(result.tokens?.[1]?.frequencyRank, undefined);
assert.equal(result.tokens?.[1]?.jlptLevel, undefined); assert.equal(result.tokens?.[1]?.jlptLevel, undefined);
@@ -3365,7 +3365,7 @@ test('tokenizeSubtitle excludes default non-independent pos2 from N+1 and freque
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false); assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false);
}); });
test('tokenizeSubtitle clears known-word highlight for exact non-independent kanji noun tokens', async () => { test('tokenizeSubtitle keeps known-word highlight for exact non-independent kanji noun tokens', async () => {
const result = await tokenizeSubtitle( const result = await tokenizeSubtitle(
'その点', 'その点',
makeDepsFromYomitanTokens( makeDepsFromYomitanTokens(
@@ -3413,7 +3413,7 @@ test('tokenizeSubtitle clears known-word highlight for exact non-independent kan
assert.equal(result.tokens?.length, 2); assert.equal(result.tokens?.length, 2);
assert.equal(result.tokens?.[0]?.isKnown, false); assert.equal(result.tokens?.[0]?.isKnown, false);
assert.equal(result.tokens?.[1]?.surface, '点'); assert.equal(result.tokens?.[1]?.surface, '点');
assert.equal(result.tokens?.[1]?.isKnown, false); assert.equal(result.tokens?.[1]?.isKnown, true);
assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false); assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false);
assert.equal(result.tokens?.[1]?.frequencyRank, undefined); assert.equal(result.tokens?.[1]?.frequencyRank, undefined);
assert.equal(result.tokens?.[1]?.jlptLevel, undefined); assert.equal(result.tokens?.[1]?.jlptLevel, undefined);
@@ -4028,7 +4028,7 @@ test('tokenizeSubtitle clears all annotations for kana-only demonstrative helper
{ {
surface: 'これで', surface: 'これで',
headword: 'これ', headword: 'これ',
isKnown: false, isKnown: true,
isNPlusOneTarget: false, isNPlusOneTarget: false,
frequencyRank: undefined, frequencyRank: undefined,
jlptLevel: undefined, jlptLevel: undefined,
@@ -4143,7 +4143,7 @@ test('tokenizeSubtitle clears all annotations for explanatory pondering endings'
{ {
surface: 'のかな', surface: 'のかな',
headword: 'の', headword: 'の',
isKnown: false, isKnown: true,
isNPlusOneTarget: false, isNPlusOneTarget: false,
frequencyRank: undefined, frequencyRank: undefined,
jlptLevel: undefined, jlptLevel: undefined,
@@ -4672,7 +4672,7 @@ test('tokenizeSubtitle clears annotations for ja-nai explanatory endings and aru
{ {
surface: 'ある', surface: 'ある',
headword: 'ある', headword: 'ある',
isKnown: false, isKnown: true,
isNPlusOneTarget: false, isNPlusOneTarget: false,
frequencyRank: undefined, frequencyRank: undefined,
jlptLevel: undefined, jlptLevel: undefined,
@@ -4717,7 +4717,7 @@ test('tokenizeSubtitle clears annotations for standalone polite copula endings w
{ {
surface: 'ですよ', surface: 'ですよ',
headword: 'です', headword: 'です',
isKnown: false, isKnown: true,
isNPlusOneTarget: false, isNPlusOneTarget: false,
frequencyRank: undefined, frequencyRank: undefined,
jlptLevel: undefined, jlptLevel: undefined,
@@ -5044,7 +5044,7 @@ test('tokenizeSubtitle clears annotations for auxiliary inflection fragments whi
{ {
surface: 'れた', surface: 'れた',
headword: 'れる', headword: 'れる',
isKnown: false, isKnown: true,
isNPlusOneTarget: false, isNPlusOneTarget: false,
frequencyRank: undefined, frequencyRank: undefined,
jlptLevel: undefined, jlptLevel: undefined,
@@ -5181,7 +5181,7 @@ test('tokenizeSubtitle clears annotations for te-kureru auxiliary helper spans',
{ {
surface: 'てく', surface: 'てく',
headword: 'てく', headword: 'てく',
isKnown: false, isKnown: true,
isNPlusOneTarget: false, isNPlusOneTarget: false,
frequencyRank: undefined, frequencyRank: undefined,
jlptLevel: undefined, jlptLevel: undefined,
@@ -5192,7 +5192,7 @@ test('tokenizeSubtitle clears annotations for te-kureru auxiliary helper spans',
{ {
surface: 'れた', surface: 'れた',
headword: 'れる', headword: 'れる',
isKnown: false, isKnown: true,
isNPlusOneTarget: false, isNPlusOneTarget: false,
frequencyRank: undefined, frequencyRank: undefined,
jlptLevel: undefined, jlptLevel: undefined,
@@ -425,6 +425,21 @@ test('shouldExcludeTokenFromSubtitleAnnotations keeps lexical tokens outside exp
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false); assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false);
}); });
test('shouldExcludeTokenFromSubtitleAnnotations still excludes lexical non-independent kanji nouns from non-known annotations', () => {
const token = makeToken({
surface: '以外',
headword: '以外',
reading: 'イガイ',
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '非自立',
pos3: '副詞可能',
});
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
assert.equal(shouldExcludeTokenFromVocabularyPersistence(token), true);
});
test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone particles auxiliaries and adnominals', () => { test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone particles auxiliaries and adnominals', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
@@ -971,8 +986,8 @@ test('annotateTokens N+1 minimum sentence words counts only eligible word tokens
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, false);
assert.equal(result[1]?.isKnown, false); assert.equal(result[1]?.isKnown, true);
assert.equal(result[2]?.isKnown, false); assert.equal(result[2]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
}); });
@@ -1186,7 +1201,7 @@ test('annotateTokens excludes default non-independent pos2 from frequency and N+
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
}); });
test('annotateTokens clears known-word status for non-independent kanji noun tokens', () => { test('annotateTokens keeps known-word status for non-independent kanji noun tokens', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: '点', surface: '点',
@@ -1211,12 +1226,41 @@ test('annotateTokens clears known-word status for non-independent kanji noun tok
{ minSentenceWordsForNPlusOne: 1 }, { minSentenceWordsForNPlusOne: 1 },
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens keeps known-word status for lexical non-independent kanji nouns', () => {
const tokens = [
makeToken({
surface: '以外',
reading: 'イガイ',
headword: '以外',
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '非自立',
pos3: '副詞可能',
startPos: 2,
endPos: 4,
frequencyRank: 437,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '以外',
}),
{ minSentenceWordsForNPlusOne: 1 },
);
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.isNPlusOneTarget, false);
});
test('annotateTokens clears all annotations for non-independent kanji noun tokens under unified gate', () => { test('annotateTokens clears all annotations for non-independent kanji noun tokens under unified gate', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
@@ -1401,7 +1445,7 @@ test('annotateTokens excludes composite tokens when all component pos tags are e
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
}); });
test('annotateTokens applies one shared exclusion gate across known N+1 frequency and JLPT', () => { test('annotateTokens lets known words bypass the shared exclusion gate for known status only', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'これで', surface: 'これで',
@@ -1425,13 +1469,13 @@ test('annotateTokens applies one shared exclusion gate across known N+1 frequenc
{ minSentenceWordsForNPlusOne: 1 }, { minSentenceWordsForNPlusOne: 1 },
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens clears known status and other annotations for kana-only non-independent noun helper merges', () => { test('annotateTokens keeps known status while clearing other annotations for kana-only non-independent noun helper merges', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'ことに', surface: 'ことに',
@@ -1455,13 +1499,13 @@ test('annotateTokens clears known status and other annotations for kana-only non
{ minSentenceWordsForNPlusOne: 1 }, { minSentenceWordsForNPlusOne: 1 },
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens clears known status and other annotations for standalone auxiliary inflection fragments', () => { test('annotateTokens keeps known status while clearing other annotations for standalone auxiliary inflection fragments', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'れる', surface: 'れる',
@@ -1497,14 +1541,14 @@ test('annotateTokens clears known status and other annotations for standalone au
); );
for (const token of result) { for (const token of result) {
assert.equal(token.isKnown, false, token.surface); assert.equal(token.isKnown, true, token.surface);
assert.equal(token.isNPlusOneTarget, false, token.surface); assert.equal(token.isNPlusOneTarget, false, token.surface);
assert.equal(token.frequencyRank, undefined, token.surface); assert.equal(token.frequencyRank, undefined, token.surface);
assert.equal(token.jlptLevel, undefined, token.surface); assert.equal(token.jlptLevel, undefined, token.surface);
} }
}); });
test('annotateTokens clears known status and other annotations for auxiliary-only te-kureru helper spans', () => { test('annotateTokens keeps known status while clearing other annotations for auxiliary-only te-kureru helper spans', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'てく', surface: 'てく',
@@ -1540,7 +1584,7 @@ test('annotateTokens clears known status and other annotations for auxiliary-onl
); );
for (const token of result) { for (const token of result) {
assert.equal(token.isKnown, false, token.surface); assert.equal(token.isKnown, true, token.surface);
assert.equal(token.isNPlusOneTarget, false, token.surface); assert.equal(token.isNPlusOneTarget, false, token.surface);
assert.equal(token.frequencyRank, undefined, token.surface); assert.equal(token.frequencyRank, undefined, token.surface);
assert.equal(token.jlptLevel, undefined, token.surface); assert.equal(token.jlptLevel, undefined, token.surface);
@@ -1576,7 +1620,7 @@ test('annotateTokens keeps lexical くれる forms eligible for annotation', ()
assert.equal(result[0]?.jlptLevel, 'N4'); assert.equal(result[0]?.jlptLevel, 'N4');
}); });
test('annotateTokens clears known status and other annotations for standalone して helper fragments', () => { test('annotateTokens keeps known status while clearing other annotations for standalone して helper fragments', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'してる', surface: 'してる',
@@ -1600,13 +1644,13 @@ test('annotateTokens clears known status and other annotations for standalone
{ minSentenceWordsForNPlusOne: 1 }, { minSentenceWordsForNPlusOne: 1 },
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens clears known status and other annotations for standalone particle fragments without POS tags', () => { test('annotateTokens keeps known status while clearing other annotations for standalone particle fragments without POS tags', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'と', surface: 'と',
@@ -1630,13 +1674,13 @@ test('annotateTokens clears known status and other annotations for standalone pa
{ minSentenceWordsForNPlusOne: 1 }, { minSentenceWordsForNPlusOne: 1 },
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens clears known status from standalone particles even when the known-word cache contains them', () => { test('annotateTokens keeps known status on standalone particles when the known-word cache contains them', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'に', surface: 'に',
@@ -1671,7 +1715,7 @@ test('annotateTokens clears known status from standalone particles even when the
{ minSentenceWordsForNPlusOne: 1 }, { minSentenceWordsForNPlusOne: 1 },
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
@@ -1728,7 +1772,7 @@ test('annotateTokens does not mark standalone connective particles as N+1', () =
assert.equal(result[1]?.jlptLevel, undefined); assert.equal(result[1]?.jlptLevel, undefined);
}); });
test('annotateTokens clears known status and other annotations for rhetorical もんか grammar particle phrases', () => { test('annotateTokens keeps known status while clearing other annotations for rhetorical もんか grammar particle phrases', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'もんか', surface: 'もんか',
@@ -1752,13 +1796,13 @@ test('annotateTokens clears known status and other annotations for rhetorical
{ minSentenceWordsForNPlusOne: 1 }, { minSentenceWordsForNPlusOne: 1 },
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens clears known status and other annotations for bare くれ auxiliary fragments', () => { test('annotateTokens keeps known status while clearing other annotations for bare くれ auxiliary fragments', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'くれ', surface: 'くれ',
@@ -1782,13 +1826,13 @@ test('annotateTokens clears known status and other annotations for bare くれ a
{ minSentenceWordsForNPlusOne: 1 }, { minSentenceWordsForNPlusOne: 1 },
); );
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens clears known status and other annotations for aru existence verbs', () => { test('annotateTokens keeps known status while clearing other annotations for aru existence verbs', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: '有る', surface: '有る',
@@ -1818,14 +1862,14 @@ test('annotateTokens clears known status and other annotations for aru existence
assert.equal(result[0]?.surface, '有る'); assert.equal(result[0]?.surface, '有る');
assert.equal(result[0]?.headword, '有る'); assert.equal(result[0]?.headword, '有る');
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.isNameMatch, false); assert.equal(result[0]?.isNameMatch, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens clears known status and other annotations for standalone quote particle and auxiliary grammar terms', () => { test('annotateTokens keeps known status while clearing other annotations for standalone quote particle and auxiliary grammar terms', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'って', surface: 'って',
@@ -1861,14 +1905,14 @@ test('annotateTokens clears known status and other annotations for standalone qu
); );
for (const token of result) { for (const token of result) {
assert.equal(token.isKnown, false, token.surface); assert.equal(token.isKnown, true, token.surface);
assert.equal(token.isNPlusOneTarget, false, token.surface); assert.equal(token.isNPlusOneTarget, false, token.surface);
assert.equal(token.frequencyRank, undefined, token.surface); assert.equal(token.frequencyRank, undefined, token.surface);
assert.equal(token.jlptLevel, undefined, token.surface); assert.equal(token.jlptLevel, undefined, token.surface);
} }
}); });
test('annotateTokens clears known status and other annotations from standalone あ interjections without POS tags', () => { test('annotateTokens keeps known status while clearing other annotations from standalone あ interjections without POS tags', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'あ', surface: 'あ',
@@ -1898,13 +1942,13 @@ test('annotateTokens clears known status and other annotations from standalone
assert.equal(result[0]?.surface, 'あ'); assert.equal(result[0]?.surface, 'あ');
assert.equal(result[0]?.headword, 'あ'); assert.equal(result[0]?.headword, 'あ');
assert.equal(result[0]?.reading, 'あ'); assert.equal(result[0]?.reading, 'あ');
assert.equal(result[0]?.isKnown, false); assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false); assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined); assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined); assert.equal(result[0]?.jlptLevel, undefined);
}); });
test('annotateTokens clears all annotations from expressive subtitle interjections without POS tags', () => { test('annotateTokens keeps known status while clearing other annotations from expressive subtitle interjections without POS tags', () => {
const tokens = [ const tokens = [
makeToken({ makeToken({
surface: 'ハァ', surface: 'ハァ',
@@ -1960,7 +2004,7 @@ test('annotateTokens clears all annotations from expressive subtitle interjectio
); );
for (const token of result.slice(0, 2)) { for (const token of result.slice(0, 2)) {
assert.equal(token.isKnown, false, token.surface); assert.equal(token.isKnown, true, token.surface);
assert.equal(token.isNPlusOneTarget, false, token.surface); assert.equal(token.isNPlusOneTarget, false, token.surface);
assert.equal(token.frequencyRank, undefined, token.surface); assert.equal(token.frequencyRank, undefined, token.surface);
assert.equal(token.jlptLevel, undefined, token.surface); assert.equal(token.jlptLevel, undefined, token.surface);
@@ -680,6 +680,11 @@ export function annotateTokens(
// Single pass: compute known word status, frequency filtering, and JLPT level together // Single pass: compute known word status, frequency filtering, and JLPT level together
const annotated = tokens.map((token, index) => { const annotated = tokens.map((token, index) => {
const isKnownForMatching = shouldComputeKnownStatus
? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode)
: false;
nPlusOneKnownStatuses[index] = isKnownForMatching;
if ( if (
sharedShouldExcludeTokenFromSubtitleAnnotations(token, { sharedShouldExcludeTokenFromSubtitleAnnotations(token, {
pos1Exclusions, pos1Exclusions,
@@ -690,18 +695,13 @@ export function annotateTokens(
pos1Exclusions, pos1Exclusions,
pos2Exclusions, pos2Exclusions,
}); });
nPlusOneKnownStatuses[index] = false;
return { return {
...strippedToken, ...strippedToken,
isKnown: false, isKnown: knownWordsEnabled ? isKnownForMatching : false,
}; };
} }
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true; const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
const isKnownForMatching = shouldComputeKnownStatus
? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode)
: false;
nPlusOneKnownStatuses[index] = isKnownForMatching;
const frequencyRank = const frequencyRank =
frequencyEnabled && !prioritizedNameMatch frequencyEnabled && !prioritizedNameMatch
+61
View File
@@ -15,6 +15,9 @@ import {
shouldHandleLaunchMpvAtEntry, shouldHandleLaunchMpvAtEntry,
shouldHandleStatsDaemonCommandAtEntry, shouldHandleStatsDaemonCommandAtEntry,
hasTransportedStartupArgs, hasTransportedStartupArgs,
shouldForwardStartupArgvViaAppControl,
applyEarlyLinuxCommandLineSwitches,
resolveLinuxPasswordStoreValue,
} from './main-entry-runtime'; } from './main-entry-runtime';
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => { test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
@@ -106,6 +109,64 @@ test('hasTransportedStartupArgs detects env-carried app args', () => {
assert.equal(hasTransportedStartupArgs({}), false); assert.equal(hasTransportedStartupArgs({}), false);
}); });
test('resolveLinuxPasswordStoreValue defaults Linux safeStorage to gnome-libsecret', () => {
assert.equal(resolveLinuxPasswordStoreValue(['SubMiner.AppImage'], 'linux'), 'gnome-libsecret');
assert.equal(
resolveLinuxPasswordStoreValue(['SubMiner.AppImage', '--password-store', 'gnome'], 'linux'),
'gnome-libsecret',
);
assert.equal(resolveLinuxPasswordStoreValue(['SubMiner.exe'], 'win32'), null);
});
test('applyEarlyLinuxCommandLineSwitches appends password store before main startup', () => {
const switches: Array<[string, string | undefined]> = [];
applyEarlyLinuxCommandLineSwitches(
{
appendSwitch: (name, value) => {
switches.push([name, value]);
},
},
['SubMiner.AppImage', '--password-store=kwallet6'],
'linux',
);
assert.deepEqual(switches, [
['enable-features', 'GlobalShortcutsPortal'],
['password-store', 'kwallet6'],
]);
});
test('transported AppImage visibility commands should forward through app control', () => {
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {
SUBMINER_APP_ARGC: '1',
SUBMINER_APP_ARG_0: '--hide-visible-overlay',
}),
true,
);
});
test('app control forwarding is only for transported runtime commands', () => {
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {}),
false,
);
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--app-ping'], {
SUBMINER_APP_ARGC: '1',
SUBMINER_APP_ARG_0: '--app-ping',
}),
false,
);
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--launch-mpv'], {
SUBMINER_APP_ARGC: '1',
SUBMINER_APP_ARG_0: '--launch-mpv',
}),
false,
);
});
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => { test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true); assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false); assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
+72 -1
View File
@@ -1,11 +1,12 @@
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import { CliArgs, parseArgs, shouldStartApp } from './cli/args'; import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
import { resolveConfigDir } from './config/path-resolution'; import { resolveConfigDir } from './config/path-resolution';
const BACKGROUND_ARG = '--background'; const BACKGROUND_ARG = '--background';
const START_ARG = '--start'; const START_ARG = '--start';
const PASSWORD_STORE_ARG = '--password-store'; const PASSWORD_STORE_ARG = '--password-store';
const DEFAULT_LINUX_PASSWORD_STORE = 'gnome-libsecret';
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD'; const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC'; const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_'; const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
@@ -34,6 +35,10 @@ type EarlyAppLike = {
setPath: (name: 'userData', value: string) => void; setPath: (name: 'userData', value: string) => void;
}; };
type CommandLineLike = {
appendSwitch: (name: string, value?: string) => void;
};
type EarlyAppPathOptions = { type EarlyAppPathOptions = {
platform?: NodeJS.Platform; platform?: NodeJS.Platform;
appDataDir?: string; appDataDir?: string;
@@ -73,6 +78,58 @@ function removePassiveStartupArgs(argv: string[]): string[] {
return filtered; return filtered;
} }
function getPasswordStoreArg(argv: string[]): string | null {
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg?.startsWith(PASSWORD_STORE_ARG)) {
continue;
}
if (arg === PASSWORD_STORE_ARG) {
const value = argv[i + 1];
if (value && !value.startsWith('--')) {
return value;
}
return null;
}
const [prefix, value] = arg.split('=', 2);
if (prefix === PASSWORD_STORE_ARG && value && value.trim().length > 0) {
return value.trim();
}
}
return null;
}
function normalizePasswordStoreArg(value: string): string {
const normalized = value.trim();
if (normalized.toLowerCase() === 'gnome') {
return DEFAULT_LINUX_PASSWORD_STORE;
}
return normalized;
}
export function resolveLinuxPasswordStoreValue(
argv: string[],
platform: NodeJS.Platform = process.platform,
): string | null {
if (platform !== 'linux') return null;
return normalizePasswordStoreArg(getPasswordStoreArg(argv) ?? DEFAULT_LINUX_PASSWORD_STORE);
}
export function applyEarlyLinuxCommandLineSwitches(
commandLine: CommandLineLike,
argv: string[],
platform: NodeJS.Platform = process.platform,
): void {
if (platform !== 'linux') return;
commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
commandLine.appendSwitch(
'password-store',
resolveLinuxPasswordStoreValue(argv, platform) ?? DEFAULT_LINUX_PASSWORD_STORE,
);
}
function consumesLaunchMpvValue(token: string): boolean { function consumesLaunchMpvValue(token: string): boolean {
return ( return (
token.startsWith('--') && token.startsWith('--') &&
@@ -90,6 +147,20 @@ export function hasTransportedStartupArgs(env: NodeJS.ProcessEnv): boolean {
return typeof env[TRANSPORTED_APP_ARGC_ENV] === 'string'; return typeof env[TRANSPORTED_APP_ARGC_ENV] === 'string';
} }
export function shouldForwardStartupArgvViaAppControl(
argv: string[],
env: NodeJS.ProcessEnv,
): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
if (!hasTransportedStartupArgs(env)) return false;
const args = parseCliArgs(argv);
if (args.help || args.appPing || args.launchMpv) return false;
if (resolveStatsDaemonCommandAction(argv) !== null) return false;
return hasExplicitCommand(args);
}
function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null { function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null {
const rawCount = env[TRANSPORTED_APP_ARGC_ENV]; const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
if (rawCount === undefined) { if (rawCount === undefined) {
+50 -11
View File
@@ -9,17 +9,20 @@ import {
normalizeLaunchMpvExtraArgs, normalizeLaunchMpvExtraArgs,
normalizeLaunchMpvTargets, normalizeLaunchMpvTargets,
normalizeStartupArgv, normalizeStartupArgv,
applyEarlyLinuxCommandLineSwitches,
sanitizeStartupEnv, sanitizeStartupEnv,
sanitizeBackgroundEnv, sanitizeBackgroundEnv,
sanitizeHelpEnv, sanitizeHelpEnv,
sanitizeLaunchMpvEnv, sanitizeLaunchMpvEnv,
hasTransportedStartupArgs, hasTransportedStartupArgs,
shouldForwardStartupArgvViaAppControl,
shouldDetachBackgroundLaunch, shouldDetachBackgroundLaunch,
shouldHandleHelpOnlyAtEntry, shouldHandleHelpOnlyAtEntry,
shouldHandleLaunchMpvAtEntry, shouldHandleLaunchMpvAtEntry,
shouldHandleStatsDaemonCommandAtEntry, shouldHandleStatsDaemonCommandAtEntry,
} from './main-entry-runtime'; } from './main-entry-runtime';
import { requestSingleInstanceLockEarly } from './main/early-single-instance'; import { requestSingleInstanceLockEarly } from './main/early-single-instance';
import { sendAppControlCommand } from './shared/app-control-client';
import { import {
detectInstalledFirstRunPluginCandidates, detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin, detectInstalledMpvPlugin,
@@ -173,6 +176,7 @@ function readConfiguredWindowsMpvLaunch(configDir: string): {
} }
process.argv = normalizeStartupArgv(process.argv, process.env); process.argv = normalizeStartupArgv(process.argv, process.env);
applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv);
applySanitizedEnv(sanitizeStartupEnv(process.env)); applySanitizedEnv(sanitizeStartupEnv(process.env));
const userDataPath = configureEarlyAppPaths(app); const userDataPath = configureEarlyAppPaths(app);
const reportFatalError = createFatalErrorReporter({ const reportFatalError = createFatalErrorReporter({
@@ -184,6 +188,44 @@ registerFatalErrorHandlers({
exit: (code) => app.exit(code), exit: (code) => app.exit(code),
}); });
function startMainProcess(): void {
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
if (!gotSingleInstanceLock) {
app.exit(0);
return;
}
try {
require('./main.js');
} catch (error) {
reportFatalError(error, {
title: 'SubMiner startup failed',
context: 'SubMiner failed while loading the main process.',
});
app.exit(1);
}
}
async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
if (!shouldForwardStartupArgvViaAppControl(process.argv, process.env)) {
return false;
}
const result = await sendAppControlCommand(process.argv, {
configDir: userDataPath,
timeoutMs: 500,
});
if (result.ok) {
app.exit(0);
return true;
}
if (!result.unavailable) {
console.error(`SubMiner app-control handoff failed: ${result.error ?? 'unknown error'}`);
app.exit(1);
return true;
}
return false;
}
if (shouldDetachBackgroundLaunch(process.argv, process.env)) { if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1); const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
const child = spawn(process.execPath, childArgs, { const child = spawn(process.execPath, childArgs, {
@@ -233,17 +275,14 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
app.exit(exitCode); app.exit(exitCode);
}); });
} else { } else {
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app); void forwardStartupArgvViaAppControlIfAvailable()
if (!gotSingleInstanceLock) { .then((forwarded) => {
app.exit(0); if (!forwarded) {
startMainProcess();
} }
try { })
require('./main.js'); .catch((error) => {
} catch (error) { console.error('SubMiner app-control handoff failed:', error);
reportFatalError(error, { startMainProcess();
title: 'SubMiner startup failed',
context: 'SubMiner failed while loading the main process.',
}); });
app.exit(1);
}
} }
+155 -19
View File
@@ -351,8 +351,13 @@ import {
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve'; import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
import { probeYoutubeTracks } from './core/services/youtube/track-probe'; import { probeYoutubeTracks } from './core/services/youtube/track-probe';
import { startStatsServer } from './core/services/stats-server'; import { startStatsServer } from './core/services/stats-server';
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js'; import {
import { toggleStatsOverlay as toggleStatsOverlayWindow } from './core/services/stats-window.js'; destroyStatsWindow,
promoteStatsOverlayAbovePlayback,
registerStatsOverlayToggle,
toggleStatsOverlay as toggleStatsOverlayWindow,
withStatsWindowLayerSuspendedForNativeDialog,
} from './core/services/stats-window.js';
import { import {
createFirstRunSetupService, createFirstRunSetupService,
getFirstRunSetupCompletionMessage, getFirstRunSetupCompletionMessage,
@@ -399,6 +404,11 @@ import {
launchWindowsMpv, launchWindowsMpv,
} from './main/runtime/windows-mpv-launch'; } from './main/runtime/windows-mpv-launch';
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection'; import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
import {
DEFAULT_JELLYFIN_CLIENT_NAME,
DEFAULT_JELLYFIN_CLIENT_VERSION,
createHostDerivedJellyfinDeviceId,
} from './main/runtime/jellyfin-device-identity';
import { import {
clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime, clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime,
isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime, isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime,
@@ -495,6 +505,7 @@ import {
} from './main/jlpt-runtime'; } from './main/jlpt-runtime';
import { createMediaRuntimeService } from './main/media-runtime'; import { createMediaRuntimeService } from './main/media-runtime';
import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime'; import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime';
import { createStatsOverlayVisibilityChangeHandler } from './main/runtime/stats-overlay-visibility';
import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime'; import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime';
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime'; import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync'; import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
@@ -502,6 +513,7 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot'; import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot';
import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import { import {
createElectronAppUpdater, createElectronAppUpdater,
@@ -608,6 +620,15 @@ const DEFAULT_MPV_LOG_FILE = resolveDefaultLogFilePath({
appDataDir: process.env.APPDATA, appDataDir: process.env.APPDATA,
}); });
const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize'; const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize';
const jellyfinSubtitleCacheIo = createJellyfinSubtitleCacheIo({
tmpDir: () => os.tmpdir(),
makeTempDir: (prefix) => fs.promises.mkdtemp(prefix),
writeFile: (filePath, bytes) => fs.promises.writeFile(filePath, bytes),
removeDir: (dir, options) => {
fs.rmSync(dir, options);
},
fetch: (url) => fetch(url),
});
const ANILIST_SETUP_RESPONSE_TYPE = 'token'; const ANILIST_SETUP_RESPONSE_TYPE = 'token';
const ANILIST_DEFAULT_CLIENT_ID = '36084'; const ANILIST_DEFAULT_CLIENT_ID = '36084';
const ANILIST_REDIRECT_URI = 'https://anilist.subminer.moe/'; const ANILIST_REDIRECT_URI = 'https://anilist.subminer.moe/';
@@ -628,6 +649,7 @@ let jellyfinPlayQuitOnDisconnectArmed = false;
const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US'; const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US';
const JELLYFIN_TICKS_PER_SECOND = 10_000_000; const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000; const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000;
const JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS = 10_000;
const DISCORD_PRESENCE_APP_ID = '1475264834730856619'; const DISCORD_PRESENCE_APP_ID = '1475264834730856619';
const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000; const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000; const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
@@ -2232,11 +2254,13 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
getModalActive: () => overlayModalInputState.getModalInputExclusive(), getModalActive: () => overlayModalInputState.getModalInputExclusive(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getForceMousePassthrough: () => appState.statsOverlayVisible, getForceMousePassthrough: () => appState.statsOverlayVisible,
getSuspendVisibleOverlay: () => appState.statsOverlayVisible,
getOverlayInteractionActive: () => visibleOverlayInteractionActive, getOverlayInteractionActive: () => visibleOverlayInteractionActive,
getWindowTracker: () => appState.windowTracker, getWindowTracker: () => appState.windowTracker,
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName, getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(), getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(), getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(),
getMacOSForegroundProbeActive: () => macOSVisibleOverlayForegroundProbeActive,
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown: boolean) => { setTrackerNotReadyWarningShown: (shown: boolean) => {
appState.trackerNotReadyWarningShown = shown; appState.trackerNotReadyWarningShown = shown;
@@ -2280,6 +2304,7 @@ const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const; const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75; const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200; const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200;
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = []; let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = []; let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderSyncInFlight = false; let windowsVisibleOverlayZOrderSyncInFlight = false;
@@ -2288,6 +2313,20 @@ let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval>
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
let lastWindowsVisibleOverlayBlurredAtMs = 0; let lastWindowsVisibleOverlayBlurredAtMs = 0;
let visibleOverlayInteractionActive = false; let visibleOverlayInteractionActive = false;
let macOSVisibleOverlayForegroundProbeActive = false;
let macOSVisibleOverlayForegroundProbeToken = 0;
let macOSVisibleOverlayForegroundProbeTimeout: ReturnType<typeof setTimeout> | null = null;
const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHandler({
setStatsOverlayVisibleState: (visible) => {
appState.statsOverlayVisible = visible;
},
resetVisibleOverlayInteraction: () => {
visibleOverlayInteractionActive = false;
},
getMainWindow: () => overlayManager.getMainWindow(),
updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
});
function clearVisibleOverlayBlurRefreshTimeouts(): void { function clearVisibleOverlayBlurRefreshTimeouts(): void {
for (const timeout of visibleOverlayBlurRefreshTimeouts) { for (const timeout of visibleOverlayBlurRefreshTimeouts) {
@@ -2303,6 +2342,49 @@ function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
windowsVisibleOverlayZOrderRetryTimeouts = []; windowsVisibleOverlayZOrderRetryTimeouts = [];
} }
function finishMacOSVisibleOverlayForegroundProbe(token: number): void {
if (token !== macOSVisibleOverlayForegroundProbeToken) {
return;
}
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
macOSVisibleOverlayForegroundProbeTimeout = null;
}
if (!macOSVisibleOverlayForegroundProbeActive) {
return;
}
macOSVisibleOverlayForegroundProbeActive = false;
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}
function startMacOSVisibleOverlayForegroundProbe(): void {
if (process.platform !== 'darwin') {
return;
}
const tracker = appState.windowTracker;
if (!tracker) {
return;
}
macOSVisibleOverlayForegroundProbeActive = true;
const token = ++macOSVisibleOverlayForegroundProbeToken;
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
}
macOSVisibleOverlayForegroundProbeTimeout = setTimeout(() => {
finishMacOSVisibleOverlayForegroundProbe(token);
}, MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS);
void tracker
.refreshNow()
.catch((error) => {
logger.warn('Failed to refresh macOS frontmost app after overlay blur', error);
})
.finally(() => {
finishMacOSVisibleOverlayForegroundProbe(token);
});
}
function getWindowsNativeWindowHandle(window: BrowserWindow): string { function getWindowsNativeWindowHandle(window: BrowserWindow): string {
const handle = window.getNativeWindowHandle(); const handle = window.getNativeWindowHandle();
return handle.length >= 8 return handle.length >= 8
@@ -2501,6 +2583,7 @@ function scheduleVisibleOverlayBlurRefresh(): void {
if (process.platform === 'win32') { if (process.platform === 'win32') {
lastWindowsVisibleOverlayBlurredAtMs = Date.now(); lastWindowsVisibleOverlayBlurredAtMs = Date.now();
} }
startMacOSVisibleOverlayForegroundProbe();
clearVisibleOverlayBlurRefreshTimeouts(); clearVisibleOverlayBlurRefreshTimeouts();
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) { for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
const refreshTimeout = setTimeout(() => { const refreshTimeout = setTimeout(() => {
@@ -2747,6 +2830,7 @@ const {
reportJellyfinRemoteStopped, reportJellyfinRemoteStopped,
startJellyfinRemoteSession, startJellyfinRemoteSession,
stopJellyfinRemoteSession, stopJellyfinRemoteSession,
cleanupJellyfinSubtitleCache,
runJellyfinCommand, runJellyfinCommand,
openJellyfinSetupWindow, openJellyfinSetupWindow,
getJellyfinClientInfo, getJellyfinClientInfo,
@@ -2758,7 +2842,9 @@ const {
}, },
getJellyfinClientInfoMainDeps: { getJellyfinClientInfoMainDeps: {
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(), getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
getDefaultJellyfinConfig: () => DEFAULT_CONFIG.jellyfin, getHostName: () => os.hostname(),
defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME,
defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION,
}, },
waitForMpvConnectedMainDeps: { waitForMpvConnectedMainDeps: {
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
@@ -2770,6 +2856,15 @@ const {
getLaunchMode: () => getResolvedConfig().mpv.launchMode, getLaunchMode: () => getResolvedConfig().mpv.launchMode,
platform: process.platform, platform: process.platform,
execPath: process.execPath, execPath: process.execPath,
getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(),
getInstalledPluginDetection: () =>
detectInstalledMpvPlugin({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: app.getPath('appData'),
mpvExecutablePath: getResolvedConfig().mpv.executablePath,
}),
getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(), getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(),
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH, defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS, defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS,
@@ -2805,6 +2900,8 @@ const {
sendMpvCommandRuntime(appState.mpvClient, command); sendMpvCommandRuntime(appState.mpvClient, command);
}, },
wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)), wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
cacheSubtitleTrack: (track) => jellyfinSubtitleCacheIo.cacheSubtitleTrack(track),
cleanupCachedSubtitles: (dirs) => jellyfinSubtitleCacheIo.cleanupCachedSubtitles(dirs),
logDebug: (message, error) => { logDebug: (message, error) => {
logger.debug(message, error); logger.debug(message, error);
}, },
@@ -2823,6 +2920,7 @@ const {
}, },
), ),
applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaults(mpvClient), applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaults(mpvClient),
showVisibleOverlay: () => setVisibleOverlayVisible(true),
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
armQuitOnDisconnect: () => { armQuitOnDisconnect: () => {
jellyfinPlayQuitOnDisconnectArmed = false; jellyfinPlayQuitOnDisconnectArmed = false;
@@ -2835,7 +2933,11 @@ const {
}, },
convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks), convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks),
setActivePlayback: (state) => { setActivePlayback: (state) => {
activeJellyfinRemotePlayback = state as ActiveJellyfinRemotePlaybackState; activeJellyfinRemotePlayback = {
...(state as ActiveJellyfinRemotePlaybackState),
stopReportsAfterMs:
state.stopReportsAfterMs ?? Date.now() + JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS,
};
}, },
setLastProgressAtMs: (value) => { setLastProgressAtMs: (value) => {
jellyfinRemoteLastProgressAtMs = value; jellyfinRemoteLastProgressAtMs = value;
@@ -2846,6 +2948,13 @@ const {
showMpvOsd: (text) => { showMpvOsd: (text) => {
showMpvOsd(text); showMpvOsd(text);
}, },
updateCurrentMediaTitle: (title) => {
mediaRuntime.updateCurrentMediaTitle(title);
},
recordJellyfinPlaybackMetadata: (metadata) => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordJellyfinPlaybackMetadata(metadata);
},
}, },
remoteComposerOptions: { remoteComposerOptions: {
getConfiguredSession: () => getConfiguredJellyfinSession(getResolvedJellyfinConfig()), getConfiguredSession: () => getConfiguredJellyfinSession(getResolvedJellyfinConfig()),
@@ -2906,11 +3015,13 @@ const {
appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession; appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession;
}, },
createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options), createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options),
defaultDeviceId: DEFAULT_CONFIG.jellyfin.deviceId, getHostName: () => os.hostname(),
defaultClientName: DEFAULT_CONFIG.jellyfin.clientName, defaultDeviceId: createHostDerivedJellyfinDeviceId(os.hostname()),
defaultClientVersion: DEFAULT_CONFIG.jellyfin.clientVersion, defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME,
defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION,
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
logWarn: (message, details) => logger.warn(message, details), logWarn: (message, details) => logger.warn(message, details),
onSessionStateChanged: () => refreshTrayMenuIfPresent(),
}, },
stopJellyfinRemoteSessionMainDeps: { stopJellyfinRemoteSessionMainDeps: {
getCurrentSession: () => appState.jellyfinRemoteSession, getCurrentSession: () => appState.jellyfinRemoteSession,
@@ -2920,6 +3031,7 @@ const {
clearActivePlayback: () => { clearActivePlayback: () => {
activeJellyfinRemotePlayback = null; activeJellyfinRemotePlayback = null;
}, },
onSessionStateChanged: () => refreshTrayMenuIfPresent(),
}, },
runJellyfinCommandMainDeps: { runJellyfinCommandMainDeps: {
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl, defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl,
@@ -2940,7 +3052,6 @@ const {
clearStoredSession: () => clearStoredSession: () =>
clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()), clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
patchJellyfinConfig: (session) => { patchJellyfinConfig: (session) => {
const clientInfo = getJellyfinClientInfo();
const recentServers = mergeJellyfinRecentServers( const recentServers = mergeJellyfinRecentServers(
session.serverUrl, session.serverUrl,
getResolvedConfig().jellyfin.recentServers || [], getResolvedConfig().jellyfin.recentServers || [],
@@ -2950,9 +3061,6 @@ const {
enabled: true, enabled: true,
serverUrl: session.serverUrl, serverUrl: session.serverUrl,
username: session.username, username: session.username,
deviceId: clientInfo.deviceId,
clientName: clientInfo.clientName,
clientVersion: clientInfo.clientVersion,
recentServers, recentServers,
}, },
}); });
@@ -3615,6 +3723,7 @@ const {
appState.yomitanSettingsWindow = null; appState.yomitanSettingsWindow = null;
}, },
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(), stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => { stopDiscordPresenceService: () => {
void appState.discordPresenceService?.stop(); void appState.discordPresenceService?.stop();
appState.discordPresenceService = null; appState.discordPresenceService = null;
@@ -3837,8 +3946,7 @@ const immersionTrackerStartupMainDeps: Parameters<
getToggleKey: () => getResolvedConfig().stats.toggleKey, getToggleKey: () => getResolvedConfig().stats.toggleKey,
resolveBounds: () => getCurrentOverlayGeometry(), resolveBounds: () => getCurrentOverlayGeometry(),
onVisibilityChanged: (visible) => { onVisibilityChanged: (visible) => {
appState.statsOverlayVisible = visible; handleStatsOverlayVisibilityChanged(visible);
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}, },
}); });
} }
@@ -4273,8 +4381,8 @@ const {
broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload); broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions); annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
}
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks(); autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
}
currentMediaTokenizationGate.updateCurrentMediaPath(path); currentMediaTokenizationGate.updateCurrentMediaPath(path);
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path); managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
startupOsdSequencer.reset(); startupOsdSequencer.reset();
@@ -4309,6 +4417,11 @@ const {
immersionMediaRuntime.syncFromCurrentMediaState(); immersionMediaRuntime.syncFromCurrentMediaState();
}, },
signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path), signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path),
markJellyfinRemotePlaybackLoaded: (path) => {
if (activeJellyfinRemotePlayback) {
activeJellyfinRemotePlayback.loadedMediaPath = path;
}
},
scheduleCharacterDictionarySync: () => { scheduleCharacterDictionarySync: () => {
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) { if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) {
return; return;
@@ -4628,7 +4741,12 @@ const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
const buildEnsureOverlayWindowLevelMainDepsHandler = const buildEnsureOverlayWindowLevelMainDepsHandler =
createBuildEnsureOverlayWindowLevelMainDepsHandler({ createBuildEnsureOverlayWindowLevelMainDepsHandler({
shouldSuppressOverlayWindowLevel: (window) =>
appState.statsOverlayVisible && window === overlayManager.getMainWindow(),
ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow), ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow),
afterEnsureOverlayWindowLevel: () => {
promoteStatsOverlayAbovePlayback();
},
}); });
const ensureOverlayWindowLevelMainDeps = buildEnsureOverlayWindowLevelMainDepsHandler(); const ensureOverlayWindowLevelMainDeps = buildEnsureOverlayWindowLevelMainDepsHandler();
const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler( const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler(
@@ -5006,6 +5124,8 @@ function getUpdateService() {
}); });
app.focus({ steal: true }); app.focus({ steal: true });
}, },
withStatsWindowLayerSuspended: (showDialog) =>
withStatsWindowLayerSuspendedForNativeDialog(showDialog),
showMessageBox: (options) => dialog.showMessageBox(options), showMessageBox: (options) => dialog.showMessageBox(options),
}); });
updateService = createUpdateService({ updateService = createUpdateService({
@@ -5349,8 +5469,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
getToggleKey: () => getResolvedConfig().stats.toggleKey, getToggleKey: () => getResolvedConfig().stats.toggleKey,
resolveBounds: () => getCurrentOverlayGeometry(), resolveBounds: () => getCurrentOverlayGeometry(),
onVisibilityChanged: (visible) => { onVisibilityChanged: (visible) => {
appState.statsOverlayVisible = visible; handleStatsOverlayVisibilityChanged(visible);
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}, },
}), }),
toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(),
@@ -5961,6 +6080,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
}, },
buildTrayMenuTemplateDeps: { buildTrayMenuTemplateDeps: {
buildTrayMenuTemplateRuntime, buildTrayMenuTemplateRuntime,
platform: process.platform,
initializeOverlayRuntime: () => initializeOverlayRuntime(), initializeOverlayRuntime: () => initializeOverlayRuntime(),
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
openSessionHelpModal: () => openSessionHelpOverlay(), openSessionHelpModal: () => openSessionHelpOverlay(),
@@ -5976,8 +6096,10 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
isJellyfinConfigured: () => isJellyfinConfigured: () =>
isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()), isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()),
isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession), isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession),
toggleJellyfinDiscovery: () => toggleJellyfinDiscovery: (checked: boolean) =>
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps()), toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps(), {
desiredActive: checked,
}),
openAnilistSetupWindow: () => openAnilistSetupWindow(), openAnilistSetupWindow: () => openAnilistSetupWindow(),
checkForUpdates: () => { checkForUpdates: () => {
void getUpdateService().checkForUpdates({ source: 'manual' }); void getUpdateService().checkForUpdates({ source: 'manual' });
@@ -6206,36 +6328,50 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
} }
} }
function notifyMpvPluginVisibleOverlayVisibility(visible: boolean): void {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
visible ? 'subminer-visible-overlay-shown' : 'subminer-visible-overlay-hidden',
]);
}
function setVisibleOverlayVisible(visible: boolean): void { function setVisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
if (!visible) { if (!visible) {
autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
} }
if (visible) { if (visible) {
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
} }
setVisibleOverlayVisibleHandler(visible); setVisibleOverlayVisibleHandler(visible);
notifyMpvPluginVisibleOverlayVisibility(visible);
syncOverlayMpvSubtitleSuppression(); syncOverlayMpvSubtitleSuppression();
} }
function toggleVisibleOverlay(): void { function toggleVisibleOverlay(): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
if (overlayManager.getVisibleOverlayVisible()) { const nextVisible = !overlayManager.getVisibleOverlayVisible();
autoplayReadyGate.markCurrentMediaAutoplayReady();
if (!nextVisible) {
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
} else { } else {
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
} }
toggleVisibleOverlayHandler(); toggleVisibleOverlayHandler();
notifyMpvPluginVisibleOverlayVisibility(nextVisible);
syncOverlayMpvSubtitleSuppression(); syncOverlayMpvSubtitleSuppression();
} }
function setOverlayVisible(visible: boolean): void { function setOverlayVisible(visible: boolean): void {
if (!visible) { if (!visible) {
autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
} }
if (visible) { if (visible) {
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
} }
setOverlayVisibleHandler(visible); setOverlayVisibleHandler(visible);
notifyMpvPluginVisibleOverlayVisibility(visible);
syncOverlayMpvSubtitleSuppression(); syncOverlayMpvSubtitleSuppression();
} }
function handleOverlayModalClosed(modal: OverlayHostedModal): void { function handleOverlayModalClosed(modal: OverlayHostedModal): void {
+43
View File
@@ -43,6 +43,49 @@ test('media path changes clear rendered subtitle state', () => {
); );
}); });
test('same media path updates do not reset autoplay ready fallback state', () => {
const source = readMainSource();
const actionBlock = source.match(
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(
actionBlock,
/annotationSubtitleWsService\.broadcast\(resetSubtitlePayload, frequencyOptions\);\s+autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);\s+\}\s+currentMediaTokenizationGate\.updateCurrentMediaPath\(path\);/,
);
});
test('manual visible overlay toggles suppress current-media autoplay release', () => {
const source = readMainSource();
const actionBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);/);
assert.ok(
actionBlock.indexOf('autoplayReadyGate.markCurrentMediaAutoplayReady();') <
actionBlock.indexOf('toggleVisibleOverlayHandler();'),
);
});
test('manual visible overlay changes notify mpv plugin visibility state', () => {
const source = readMainSource();
const setBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const toggleBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(setBlock);
assert.ok(toggleBlock);
assert.match(setBlock, /notifyMpvPluginVisibleOverlayVisibility\(visible\);/);
assert.match(toggleBlock, /const nextVisible = !overlayManager\.getVisibleOverlayVisible\(\);/);
assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/);
});
test('main process uses one shared mpv plugin runtime config helper', () => { test('main process uses one shared mpv plugin runtime config helper', () => {
const source = readMainSource(); const source = readMainSource();
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/); assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
+15 -3
View File
@@ -9,6 +9,7 @@ type MockWindow = {
ignoreMouseEvents: boolean; ignoreMouseEvents: boolean;
forwardedIgnoreMouseEvents: boolean; forwardedIgnoreMouseEvents: boolean;
webContentsFocused: boolean; webContentsFocused: boolean;
alwaysOnTopCalls: string[];
showCount: number; showCount: number;
hideCount: number; hideCount: number;
sent: unknown[][]; sent: unknown[][];
@@ -53,6 +54,7 @@ function createMockWindow(): MockWindow & {
ignoreMouseEvents: false, ignoreMouseEvents: false,
forwardedIgnoreMouseEvents: false, forwardedIgnoreMouseEvents: false,
webContentsFocused: false, webContentsFocused: false,
alwaysOnTopCalls: [],
showCount: 0, showCount: 0,
hideCount: 0, hideCount: 0,
sent: [], sent: [],
@@ -72,7 +74,9 @@ function createMockWindow(): MockWindow & {
state.ignoreMouseEvents = ignore; state.ignoreMouseEvents = ignore;
state.forwardedIgnoreMouseEvents = options?.forward === true; state.forwardedIgnoreMouseEvents = options?.forward === true;
}, },
setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {}, setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
state.alwaysOnTopCalls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`);
},
moveTop: () => {}, moveTop: () => {},
getShowCount: () => state.showCount, getShowCount: () => state.showCount,
getHideCount: () => state.hideCount, getHideCount: () => state.hideCount,
@@ -155,6 +159,13 @@ function createMockWindow(): MockWindow & {
}, },
}); });
Object.defineProperty(window, 'alwaysOnTopCalls', {
get: () => state.alwaysOnTopCalls,
set: (value: string[]) => {
state.alwaysOnTopCalls = value;
},
});
Object.defineProperty(window, 'url', { Object.defineProperty(window, 'url', {
get: () => state.url, get: () => state.url,
set: (value: string) => { set: (value: string) => {
@@ -219,6 +230,7 @@ test('sendToActiveOverlayWindow targets modal window with full geometry and trac
runtime.notifyOverlayModalOpened('runtime-options'); runtime.notifyOverlayModalOpened('runtime-options');
assert.equal(window.getShowCount(), 1); assert.equal(window.getShowCount(), 1);
assert.equal(window.isFocused(), true); assert.equal(window.isFocused(), true);
assert.deepEqual(window.alwaysOnTopCalls, ['top:true:screen-saver:3']);
assert.deepEqual(window.sent, [['runtime-options:open']]); assert.deepEqual(window.sent, [['runtime-options:open']]);
}); });
@@ -313,7 +325,7 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
}); });
runtime.sendToActiveOverlayWindow( runtime.sendToActiveOverlayWindow(
'subsync:open-manual', 'subsync:open-manual',
{ sourceTracks: [] }, { ffsubsyncAvailable: true, sourceTracks: [] },
{ {
restoreOnModalClose: 'subsync', restoreOnModalClose: 'subsync',
}, },
@@ -459,7 +471,7 @@ test('modal runtime notifies callers when modal input state becomes active/inact
}); });
runtime.sendToActiveOverlayWindow( runtime.sendToActiveOverlayWindow(
'subsync:open-manual', 'subsync:open-manual',
{ sourceTracks: [] }, { ffsubsyncAvailable: true, sourceTracks: [] },
{ {
restoreOnModalClose: 'subsync', restoreOnModalClose: 'subsync',
}, },
+1 -1
View File
@@ -138,7 +138,7 @@ export function createOverlayModalRuntimeService(
const elevateModalWindow = (window: BrowserWindow): void => { const elevateModalWindow = (window: BrowserWindow): void => {
if (window.isDestroyed()) return; if (window.isDestroyed()) return;
window.setAlwaysOnTop(true, 'screen-saver', 1); window.setAlwaysOnTop(true, 'screen-saver', 3);
window.moveTop(); window.moveTop();
}; };
+5
View File
@@ -11,11 +11,13 @@ export interface OverlayVisibilityRuntimeDeps {
getModalActive: () => boolean; getModalActive: () => boolean;
getVisibleOverlayVisible: () => boolean; getVisibleOverlayVisible: () => boolean;
getForceMousePassthrough: () => boolean; getForceMousePassthrough: () => boolean;
getSuspendVisibleOverlay?: () => boolean;
getOverlayInteractionActive?: () => boolean; getOverlayInteractionActive?: () => boolean;
getWindowTracker: () => BaseWindowTracker | null; getWindowTracker: () => BaseWindowTracker | null;
getLastKnownWindowsForegroundProcessName?: () => string | null; getLastKnownWindowsForegroundProcessName?: () => string | null;
getWindowsOverlayProcessName?: () => string | null; getWindowsOverlayProcessName?: () => string | null;
getWindowsFocusHandoffGraceActive?: () => boolean; getWindowsFocusHandoffGraceActive?: () => boolean;
getMacOSForegroundProbeActive?: () => boolean;
getTrackerNotReadyWarningShown: () => boolean; getTrackerNotReadyWarningShown: () => boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void; setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
@@ -43,6 +45,7 @@ export function createOverlayVisibilityRuntimeService(
updateVisibleOverlayVisibility(): void { updateVisibleOverlayVisibility(): void {
const visibleOverlayVisible = deps.getVisibleOverlayVisible(); const visibleOverlayVisible = deps.getVisibleOverlayVisible();
const forceMousePassthrough = deps.getForceMousePassthrough(); const forceMousePassthrough = deps.getForceMousePassthrough();
const suspendVisibleOverlay = deps.getSuspendVisibleOverlay?.() ?? false;
const windowTracker = deps.getWindowTracker(); const windowTracker = deps.getWindowTracker();
const mainWindow = deps.getMainWindow(); const mainWindow = deps.getMainWindow();
@@ -50,12 +53,14 @@ export function createOverlayVisibilityRuntimeService(
visibleOverlayVisible, visibleOverlayVisible,
modalActive: deps.getModalActive(), modalActive: deps.getModalActive(),
forceMousePassthrough, forceMousePassthrough,
suspendVisibleOverlay,
overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false, overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false,
mainWindow, mainWindow,
windowTracker, windowTracker,
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(), lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null, windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null,
windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false, windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false,
macOSForegroundProbeActive: deps.getMacOSForegroundProbeActive?.() ?? false,
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(), trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
setTrackerNotReadyWarningShown: (shown: boolean) => { setTrackerNotReadyWarningShown: (shown: boolean) => {
deps.setTrackerNotReadyWarningShown(shown); deps.setTrackerNotReadyWarningShown(shown);
+46 -1
View File
@@ -40,18 +40,63 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'), destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'), clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
}); });
cleanup(); cleanup();
assert.equal(calls.length, 30); assert.equal(calls.length, 31);
assert.equal(calls[0], 'destroy-tray'); assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence'); assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
assert.ok(calls.includes('clear-windows-visible-overlay-poll')); assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts')); assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket')); assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
}); });
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', () => {
const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler({
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
destroyMainOverlayWindow: () => {},
destroyModalOverlayWindow: () => {},
destroyYomitanParserWindow: () => {},
clearYomitanParserState: () => {},
stopWindowTracker: () => {},
flushMpvLog: () => {},
destroyMpvSocket: () => {},
clearReconnectTimer: () => {},
destroySubtitleTimingTracker: () => {},
destroyImmersionTracker: () => {},
destroyAnkiIntegration: () => {},
destroyAnilistSetupWindow: () => {},
clearAnilistSetupWindow: () => {},
destroyJellyfinSetupWindow: () => {},
clearJellyfinSetupWindow: () => {},
destroyFirstRunSetupWindow: () => {},
clearFirstRunSetupWindow: () => {},
destroyYomitanSettingsWindow: () => {},
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {
calls.push('stop-jellyfin-remote');
throw new Error('stop failed');
},
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
assert.throws(() => cleanup(), /stop failed/);
assert.deepEqual(calls, ['stop-jellyfin-remote', 'cleanup-jellyfin-subtitles']);
});
test('should restore windows on activate requires initialized runtime and no windows', () => { test('should restore windows on activate requires initialized runtime and no windows', () => {
let initialized = false; let initialized = false;
let windowCount = 1; let windowCount = 1;
@@ -28,6 +28,7 @@ export function createOnWillQuitCleanupHandler(deps: {
destroyYomitanSettingsWindow: () => void; destroyYomitanSettingsWindow: () => void;
clearYomitanSettingsWindow: () => void; clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void; stopJellyfinRemoteSession: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void; stopDiscordPresenceService: () => void;
}) { }) {
return (): void => { return (): void => {
@@ -59,7 +60,11 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.clearFirstRunSetupWindow(); deps.clearFirstRunSetupWindow();
deps.destroyYomitanSettingsWindow(); deps.destroyYomitanSettingsWindow();
deps.clearYomitanSettingsWindow(); deps.clearYomitanSettingsWindow();
try {
deps.stopJellyfinRemoteSession(); deps.stopJellyfinRemoteSession();
} finally {
deps.cleanupJellyfinSubtitleCache();
}
deps.stopDiscordPresenceService(); deps.stopDiscordPresenceService();
}; };
} }
@@ -69,6 +69,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'), clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
}); });
@@ -89,6 +90,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('destroy-first-run-window')); assert.ok(calls.includes('destroy-first-run-window'));
assert.ok(calls.includes('destroy-yomitan-settings-window')); assert.ok(calls.includes('destroy-yomitan-settings-window'));
assert.ok(calls.includes('stop-jellyfin-remote')); assert.ok(calls.includes('stop-jellyfin-remote'));
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
assert.ok(calls.includes('stop-discord-presence')); assert.ok(calls.includes('stop-discord-presence'));
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop')); assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts')); assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
@@ -142,6 +144,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
getYomitanSettingsWindow: () => null, getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {}, stopJellyfinRemoteSession: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {}, stopDiscordPresenceService: () => {},
}); });
@@ -190,6 +193,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
getYomitanSettingsWindow: () => null, getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {}, stopJellyfinRemoteSession: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {}, stopDiscordPresenceService: () => {},
}); });
@@ -57,6 +57,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
clearYomitanSettingsWindow: () => void; clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void; stopJellyfinRemoteSession: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void; stopDiscordPresenceService: () => void;
}) { }) {
return () => ({ return () => ({
@@ -139,6 +140,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
}, },
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(), clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(), stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(), stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
}); });
} }
+80 -1
View File
@@ -46,7 +46,6 @@ test('autoplay ready gate suppresses duplicate media signals for the same media'
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
), ),
); );
assert.equal(scheduled.length > 0, true);
}); });
test('autoplay ready gate retry loop does not re-signal plugin readiness', async () => { test('autoplay ready gate retry loop does not re-signal plugin readiness', async () => {
@@ -144,6 +143,86 @@ test('autoplay ready gate does not unpause again after a later manual pause on t
); );
}); });
test('autoplay ready gate cancels release retries after playback is paused again', async () => {
const commands: Array<Array<string | boolean>> = [];
const scheduled: Array<() => void> = [];
let playbackPaused = true;
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => playbackPaused,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => playbackPaused,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
if (command[0] === 'set_property' && command[1] === 'pause' && command[2] === false) {
playbackPaused = false;
}
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
schedule: (callback) => {
scheduled.push(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
playbackPaused = true;
const retry = scheduled.shift();
retry?.();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
commands.filter(
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
).length,
1,
);
});
test('autoplay ready gate suppresses release after manual current-media dismissal', async () => {
const commands: Array<Array<string | boolean>> = [];
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.markCurrentMediaAutoplayReady();
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands, []);
});
test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => { test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => {
const commands: Array<Array<string | boolean>> = []; const commands: Array<Array<string | boolean>> = [];
let targetReady = false; let targetReady = false;
+16
View File
@@ -39,6 +39,12 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const getSignalMediaPath = (): string => const getSignalMediaPath = (): string =>
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__'; deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
const markCurrentMediaAutoplayReady = (): void => {
pendingAutoplayReadySignal = null;
autoPlayReadySignalMediaPath = getSignalMediaPath();
autoPlayReadySignalGeneration += 1;
};
const maybeSignalPluginAutoplayReady = ( const maybeSignalPluginAutoplayReady = (
payload: SubtitleData, payload: SubtitleData,
options?: { forceWhilePaused?: boolean }, options?: { forceWhilePaused?: boolean },
@@ -58,6 +64,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
forceWhilePaused: options?.forceWhilePaused === true, forceWhilePaused: options?.forceWhilePaused === true,
retryDelayMs: releaseRetryDelayMs, retryDelayMs: releaseRetryDelayMs,
}); });
let releaseUnpauseSent = false;
const isPlaybackPaused = async (client: MpvClientLike): Promise<boolean> => { const isPlaybackPaused = async (client: MpvClientLike): Promise<boolean> => {
try { try {
@@ -102,12 +109,20 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
return; return;
} }
if (releaseUnpauseSent && deps.getPlaybackPaused() === true) {
deps.logDebug(
`[autoplay-ready] stopped release retries after playback paused again for media ${mediaPath}`,
);
return;
}
const shouldUnpause = await isPlaybackPaused(mpvClient); const shouldUnpause = await isPlaybackPaused(mpvClient);
if (!shouldUnpause) { if (!shouldUnpause) {
return; return;
} }
mpvClient.send({ command: ['set_property', 'pause', false] }); mpvClient.send({ command: ['set_property', 'pause', false] });
releaseUnpauseSent = true;
if (attempt < maxReleaseAttempts) { if (attempt < maxReleaseAttempts) {
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs); deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
} }
@@ -153,6 +168,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
flushPendingAutoplayReadySignal, flushPendingAutoplayReadySignal,
getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath, getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath,
invalidatePendingAutoplayReadyFallbacks, invalidatePendingAutoplayReadyFallbacks,
markCurrentMediaAutoplayReady,
maybeSignalPluginAutoplayReady, maybeSignalPluginAutoplayReady,
}; };
} }
@@ -87,6 +87,9 @@ export function composeJellyfinRemoteHandlers(
getActivePlayback: options.getActivePlayback, getActivePlayback: options.getActivePlayback,
clearActivePlayback: options.clearActivePlayback, clearActivePlayback: options.clearActivePlayback,
getSession: options.getSession, getSession: options.getSession,
getMpvClient: options.getMpvClient,
getNow: options.getNow,
ticksPerSecond: options.ticksPerSecond,
logDebug: options.logDebug, logDebug: options.logDebug,
}); });
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler( const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
@@ -101,6 +104,7 @@ export function composeJellyfinRemoteHandlers(
getConfiguredSession: options.getConfiguredSession, getConfiguredSession: options.getConfiguredSession,
getClientInfo: options.getClientInfo, getClientInfo: options.getClientInfo,
getJellyfinConfig: options.getJellyfinConfig, getJellyfinConfig: options.getJellyfinConfig,
getActivePlayback: options.getActivePlayback,
playJellyfinItem: options.playJellyfinItem, playJellyfinItem: options.playJellyfinItem,
logWarn: options.logWarn, logWarn: options.logWarn,
}); });
@@ -13,11 +13,9 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
}, },
getJellyfinClientInfoMainDeps: { getJellyfinClientInfoMainDeps: {
getResolvedJellyfinConfig: () => ({}) as never, getResolvedJellyfinConfig: () => ({}) as never,
getDefaultJellyfinConfig: () => ({ getHostName: () => 'workstation',
clientName: 'SubMiner', defaultClientName: 'SubMiner',
clientVersion: 'test', defaultClientVersion: 'test',
deviceId: 'dev',
}),
}, },
waitForMpvConnectedMainDeps: { waitForMpvConnectedMainDeps: {
getMpvClient: () => null, getMpvClient: () => null,
@@ -50,6 +48,8 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
getMpvClient: () => null, getMpvClient: () => null,
sendMpvCommand: () => {}, sendMpvCommand: () => {},
wait: async () => {}, wait: async () => {},
cacheSubtitleTrack: async () => ({ path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' }),
cleanupCachedSubtitles: () => {},
logDebug: () => {}, logDebug: () => {},
}, },
playJellyfinItemInMpvMainDeps: { playJellyfinItemInMpvMainDeps: {
@@ -58,11 +58,16 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
mode: 'direct', mode: 'direct',
url: 'https://example.test/video.m3u8', url: 'https://example.test/video.m3u8',
title: 'Episode 1', title: 'Episode 1',
itemTitle: 'Episode 1',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0, startTimeTicks: 0,
audioStreamIndex: null, audioStreamIndex: null,
subtitleStreamIndex: null, subtitleStreamIndex: null,
}), }),
applyJellyfinMpvDefaults: () => {}, applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: () => {}, sendMpvCommand: () => {},
armQuitOnDisconnect: () => {}, armQuitOnDisconnect: () => {},
schedule: () => undefined, schedule: () => undefined,
@@ -133,6 +138,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
defaultDeviceId: 'dev', defaultDeviceId: 'dev',
defaultClientName: 'SubMiner', defaultClientName: 'SubMiner',
defaultClientVersion: 'test', defaultClientVersion: 'test',
getHostName: () => 'workstation',
logInfo: () => {}, logInfo: () => {},
logWarn: () => {}, logWarn: () => {},
}, },
@@ -189,6 +195,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function'); assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function'); assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
assert.equal(typeof composed.playJellyfinItemInMpv, 'function'); assert.equal(typeof composed.playJellyfinItemInMpv, 'function');
assert.equal(typeof composed.cleanupJellyfinSubtitleCache, 'function');
assert.equal(typeof composed.startJellyfinRemoteSession, 'function'); assert.equal(typeof composed.startJellyfinRemoteSession, 'function');
assert.equal(typeof composed.stopJellyfinRemoteSession, 'function'); assert.equal(typeof composed.stopJellyfinRemoteSession, 'function');
assert.equal(typeof composed.runJellyfinCommand, 'function'); assert.equal(typeof composed.runJellyfinCommand, 'function');
@@ -100,7 +100,11 @@ export type JellyfinRuntimeComposerOptions = ComposerInputs<{
>; >;
startJellyfinRemoteSessionMainDeps: Omit< startJellyfinRemoteSessionMainDeps: Omit<
StartRemoteSessionMainDeps, StartRemoteSessionMainDeps,
'getJellyfinConfig' | 'handlePlay' | 'handlePlaystate' | 'handleGeneralCommand' | 'getJellyfinConfig'
| 'getClientInfo'
| 'handlePlay'
| 'handlePlaystate'
| 'handleGeneralCommand'
>; >;
stopJellyfinRemoteSessionMainDeps: Parameters< stopJellyfinRemoteSessionMainDeps: Parameters<
typeof createBuildStopJellyfinRemoteSessionMainDepsHandler typeof createBuildStopJellyfinRemoteSessionMainDepsHandler
@@ -142,6 +146,7 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{
typeof composeJellyfinRemoteHandlers typeof composeJellyfinRemoteHandlers
>['handleJellyfinRemoteGeneralCommand']; >['handleJellyfinRemoteGeneralCommand'];
playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>; playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>;
cleanupJellyfinSubtitleCache: () => void;
startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>; startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>; stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>;
runJellyfinCommand: ReturnType<typeof createRunJellyfinCommandHandler>; runJellyfinCommand: ReturnType<typeof createRunJellyfinCommandHandler>;
@@ -235,6 +240,7 @@ export function composeJellyfinRuntimeHandlers(
createBuildStartJellyfinRemoteSessionMainDepsHandler({ createBuildStartJellyfinRemoteSessionMainDepsHandler({
...options.startJellyfinRemoteSessionMainDeps, ...options.startJellyfinRemoteSessionMainDeps,
getJellyfinConfig: () => getResolvedJellyfinConfig(), getJellyfinConfig: () => getResolvedJellyfinConfig(),
getClientInfo: () => getJellyfinClientInfo(),
handlePlay: (payload) => handleJellyfinRemotePlay(payload), handlePlay: (payload) => handleJellyfinRemotePlay(payload),
handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload), handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload),
handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload), handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload),
@@ -280,6 +286,7 @@ export function composeJellyfinRuntimeHandlers(
handleJellyfinRemotePlaystate, handleJellyfinRemotePlaystate,
handleJellyfinRemoteGeneralCommand, handleJellyfinRemoteGeneralCommand,
playJellyfinItemInMpv, playJellyfinItemInMpv,
cleanupJellyfinSubtitleCache: () => preloadJellyfinExternalSubtitles.cleanupCachedSubtitles(),
startJellyfinRemoteSession, startJellyfinRemoteSession,
stopJellyfinRemoteSession, stopJellyfinRemoteSession,
runJellyfinCommand, runJellyfinCommand,
@@ -48,6 +48,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
getYomitanSettingsWindow: () => null, getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: async () => {}, stopJellyfinRemoteSession: async () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {}, stopDiscordPresenceService: () => {},
}, },
shouldRestoreWindowsOnActivateMainDeps: { shouldRestoreWindowsOnActivateMainDeps: {
+1
View File
@@ -7,6 +7,7 @@ export * from '../jellyfin-client-info';
export * from '../jellyfin-client-info-main-deps'; export * from '../jellyfin-client-info-main-deps';
export * from '../jellyfin-command-dispatch'; export * from '../jellyfin-command-dispatch';
export * from '../jellyfin-command-dispatch-main-deps'; export * from '../jellyfin-command-dispatch-main-deps';
export * from '../jellyfin-device-identity';
export * from '../jellyfin-playback-launch'; export * from '../jellyfin-playback-launch';
export * from '../jellyfin-playback-launch-main-deps'; export * from '../jellyfin-playback-launch-main-deps';
export * from '../jellyfin-remote-commands'; export * from '../jellyfin-remote-commands';
+33 -7
View File
@@ -89,16 +89,13 @@ test('jellyfin auth handler processes login', async () => {
enabled: true, enabled: true,
serverUrl: 'http://localhost', serverUrl: 'http://localhost',
username: 'user', username: 'user',
deviceId: 'd1',
clientName: 'SubMiner',
clientVersion: '1.0',
recentServers: ['http://localhost'], recentServers: ['http://localhost'],
}, },
}); });
assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded'))); assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded')));
}); });
test('persistJellyfinAuthSession stores client metadata and recent servers', () => { test('persistJellyfinAuthSession stores session config and recent servers', () => {
let patchPayload: unknown = null; let patchPayload: unknown = null;
let storedSession: unknown = null; let storedSession: unknown = null;
@@ -134,9 +131,6 @@ test('persistJellyfinAuthSession stores client metadata and recent servers', ()
enabled: true, enabled: true,
serverUrl: 'http://localhost:8096', serverUrl: 'http://localhost:8096',
username: 'alice', username: 'alice',
deviceId: 'device-1',
clientName: 'SubMiner',
clientVersion: '1.0',
recentServers: [ recentServers: [
'http://localhost:8096', 'http://localhost:8096',
'http://old.example:8096', 'http://old.example:8096',
@@ -146,6 +140,38 @@ test('persistJellyfinAuthSession stores client metadata and recent servers', ()
}); });
}); });
test('persistJellyfinAuthSession does not write generated local device id to config', () => {
let patchPayload: unknown = null;
persistJellyfinAuthSession({
session: {
serverUrl: 'http://localhost:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
},
clientInfo: {
deviceId: 'subminer-local-pc',
clientName: 'SubMiner',
clientVersion: '1.0',
},
existingRecentServers: [],
saveStoredSession: () => {},
patchRawConfig: (patch) => {
patchPayload = patch;
},
});
assert.deepEqual(patchPayload, {
jellyfin: {
enabled: true,
serverUrl: 'http://localhost:8096',
username: 'alice',
recentServers: ['http://localhost:8096'],
},
});
});
test('jellyfin auth handler no-ops when no auth command', async () => { test('jellyfin auth handler no-ops when no auth command', async () => {
const handleAuth = createHandleJellyfinAuthCommands({ const handleAuth = createHandleJellyfinAuthCommands({
patchRawConfig: () => {}, patchRawConfig: () => {},
-9
View File
@@ -53,9 +53,6 @@ export function persistJellyfinAuthSession(deps: {
enabled: boolean; enabled: boolean;
serverUrl: string; serverUrl: string;
username: string; username: string;
deviceId: string;
clientName: string;
clientVersion: string;
recentServers: string[]; recentServers: string[];
}>; }>;
}) => void; }) => void;
@@ -69,9 +66,6 @@ export function persistJellyfinAuthSession(deps: {
enabled: true, enabled: true,
serverUrl: deps.session.serverUrl, serverUrl: deps.session.serverUrl,
username: deps.session.username, username: deps.session.username,
deviceId: deps.clientInfo.deviceId,
clientName: deps.clientInfo.clientName,
clientVersion: deps.clientInfo.clientVersion,
recentServers: mergeJellyfinRecentServers( recentServers: mergeJellyfinRecentServers(
deps.session.serverUrl, deps.session.serverUrl,
deps.existingRecentServers || [], deps.existingRecentServers || [],
@@ -86,9 +80,6 @@ export function createHandleJellyfinAuthCommands(deps: {
enabled: boolean; enabled: boolean;
serverUrl: string; serverUrl: string;
username: string; username: string;
deviceId: string;
clientName: string;
clientVersion: string;
}>; }>;
}) => void; }) => void;
authenticateWithPassword: ( authenticateWithPassword: (
@@ -19,12 +19,15 @@ test('get resolved jellyfin config main deps builder maps callbacks', () => {
test('get jellyfin client info main deps builder maps callbacks', () => { test('get jellyfin client info main deps builder maps callbacks', () => {
const configured = { clientName: 'Configured' }; const configured = { clientName: 'Configured' };
const defaults = { clientName: 'Default' };
const deps = createBuildGetJellyfinClientInfoMainDepsHandler({ const deps = createBuildGetJellyfinClientInfoMainDepsHandler({
getResolvedJellyfinConfig: () => configured as never, getResolvedJellyfinConfig: () => configured as never,
getDefaultJellyfinConfig: () => defaults as never, getHostName: () => 'workstation',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0.0',
})(); })();
assert.equal(deps.getResolvedJellyfinConfig(), configured); assert.equal(deps.getResolvedJellyfinConfig(), configured);
assert.equal(deps.getDefaultJellyfinConfig(), defaults); assert.equal(deps.getHostName?.(), 'workstation');
assert.equal(deps.defaultClientName, 'SubMiner');
assert.equal(deps.defaultClientVersion, '1.0.0');
}); });
@@ -23,6 +23,8 @@ export function createBuildGetJellyfinClientInfoMainDepsHandler(
) { ) {
return (): GetJellyfinClientInfoMainDeps => ({ return (): GetJellyfinClientInfoMainDeps => ({
getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(), getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(),
getDefaultJellyfinConfig: () => deps.getDefaultJellyfinConfig(), getHostName: deps.getHostName ? () => deps.getHostName?.() || '' : undefined,
defaultClientName: deps.defaultClientName,
defaultClientVersion: deps.defaultClientVersion,
}); });
} }
+31 -17
View File
@@ -80,23 +80,20 @@ test('get resolved jellyfin config uses stored user id when env token set withou
test('jellyfin client info resolves defaults when fields are missing', () => { test('jellyfin client info resolves defaults when fields are missing', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({ const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () => ({ clientName: '', clientVersion: '', deviceId: '' }) as never, getResolvedJellyfinConfig: () => ({ clientName: '' }) as never,
getDefaultJellyfinConfig: () => getHostName: () => 'workstation',
({ defaultClientName: 'SubMiner',
clientName: 'SubMiner', defaultClientVersion: '1.0.0',
clientVersion: '1.0.0',
deviceId: 'default-device',
}) as never,
}); });
assert.deepEqual(getClientInfo(), { assert.deepEqual(getClientInfo(), {
clientName: 'SubMiner', clientName: 'SubMiner',
clientVersion: '1.0.0', clientVersion: '1.0.0',
deviceId: 'default-device', deviceId: 'workstation',
}); });
}); });
test('jellyfin client info keeps explicit config values', () => { test('jellyfin client info ignores legacy configured client name, device id, and version', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({ const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig: () =>
({ ({
@@ -104,17 +101,34 @@ test('jellyfin client info keeps explicit config values', () => {
clientVersion: '2.3.4', clientVersion: '2.3.4',
deviceId: 'custom-device', deviceId: 'custom-device',
}) as never, }) as never,
getDefaultJellyfinConfig: () => getHostName: () => 'Kyle-PC',
({ defaultClientName: 'SubMiner',
clientName: 'SubMiner', defaultClientVersion: '1.0.0',
clientVersion: '1.0.0',
deviceId: 'default-device',
}) as never,
}); });
assert.deepEqual(getClientInfo(), { assert.deepEqual(getClientInfo(), {
clientName: 'Custom', clientName: 'SubMiner',
clientVersion: '2.3.4', clientVersion: '1.0.0',
deviceId: 'Kyle-PC',
});
});
test('jellyfin client info ignores legacy configured device id and client version', () => {
const getClientInfo = createGetJellyfinClientInfoHandler({
getResolvedJellyfinConfig: () =>
({
clientName: 'SubMiner',
clientVersion: '9.9.9',
deviceId: 'custom-device', deviceId: 'custom-device',
}) as never,
getHostName: () => 'media-box',
defaultClientName: 'SubMiner',
defaultClientVersion: '1.0.0',
});
assert.deepEqual(getClientInfo(), {
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'media-box',
}); });
}); });
+13 -11
View File
@@ -1,5 +1,10 @@
import type { JellyfinStoredSession } from '../../core/services/jellyfin-token-store'; import type { JellyfinStoredSession } from '../../core/services/jellyfin-token-store';
import type { ResolvedConfig } from '../../types'; import type { ResolvedConfig } from '../../types';
import {
DEFAULT_JELLYFIN_CLIENT_NAME,
DEFAULT_JELLYFIN_CLIENT_VERSION,
createHostDerivedJellyfinDeviceId,
} from './jellyfin-device-identity';
type ResolvedJellyfinConfig = ResolvedConfig['jellyfin']; type ResolvedJellyfinConfig = ResolvedConfig['jellyfin'];
type ResolvedJellyfinConfigWithSession = ResolvedJellyfinConfig & { type ResolvedJellyfinConfigWithSession = ResolvedJellyfinConfig & {
@@ -42,25 +47,22 @@ export function createGetResolvedJellyfinConfigHandler(deps: {
} }
export function createGetJellyfinClientInfoHandler(deps: { export function createGetJellyfinClientInfoHandler(deps: {
getResolvedJellyfinConfig: () => Partial< getResolvedJellyfinConfig: () => unknown;
Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'> getHostName?: () => string;
>; defaultClientName?: string;
getDefaultJellyfinConfig: () => Partial< defaultClientVersion?: string;
Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'>
>;
}) { }) {
return ( return (
config = deps.getResolvedJellyfinConfig(), _config = deps.getResolvedJellyfinConfig(),
): { ): {
clientName: string; clientName: string;
clientVersion: string; clientVersion: string;
deviceId: string; deviceId: string;
} => { } => {
const defaults = deps.getDefaultJellyfinConfig();
return { return {
clientName: config.clientName || defaults.clientName || '', clientName: deps.defaultClientName || DEFAULT_JELLYFIN_CLIENT_NAME,
clientVersion: config.clientVersion || defaults.clientVersion || '', clientVersion: deps.defaultClientVersion || DEFAULT_JELLYFIN_CLIENT_VERSION,
deviceId: config.deviceId || defaults.deviceId || '', deviceId: createHostDerivedJellyfinDeviceId(deps.getHostName?.() || ''),
}; };
}; };
} }
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createHostDerivedJellyfinDeviceId,
resolveJellyfinRemoteDeviceName,
} from './jellyfin-device-identity';
test('createHostDerivedJellyfinDeviceId uses the hostname as the stable id', () => {
assert.equal(createHostDerivedJellyfinDeviceId('Kyle-PC.local'), 'Kyle-PC.local');
assert.equal(createHostDerivedJellyfinDeviceId(''), 'device');
});
test('resolveJellyfinRemoteDeviceName uses hostname by default', () => {
assert.equal(
resolveJellyfinRemoteDeviceName({
hostName: 'kyle-pc',
}),
'kyle-pc',
);
});
test('resolveJellyfinRemoteDeviceName falls back when hostname is empty', () => {
assert.equal(resolveJellyfinRemoteDeviceName({ hostName: '' }), 'device');
});
@@ -0,0 +1,18 @@
export const DEFAULT_JELLYFIN_CLIENT_VERSION = '0.1.0';
export const DEFAULT_JELLYFIN_CLIENT_NAME = 'SubMiner';
export function normalizeJellyfinHostName(value: string): string {
return value.trim();
}
export function createHostDerivedJellyfinDeviceId(hostName: string): string {
return normalizeJellyfinHostName(hostName) || 'device';
}
export function resolveJellyfinDeviceId(params: { hostName: string }): string {
return createHostDerivedJellyfinDeviceId(params.hostName);
}
export function resolveJellyfinRemoteDeviceName(params: { hostName: string }): string {
return normalizeJellyfinHostName(params.hostName) || 'device';
}

Some files were not shown because too many files have changed in this diff Show More