mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
45df3c466b
|
|||
|
6eda768261
|
|||
|
ceea10cba1
|
|||
|
9d73971f3b
|
|||
|
a2735eaedc
|
@@ -2,9 +2,11 @@ project_name: "SubMiner"
|
||||
default_status: "To Do"
|
||||
statuses: ["To Do", "In Progress", "Done"]
|
||||
labels: []
|
||||
definition_of_done: []
|
||||
date_format: yyyy-mm-dd
|
||||
max_column_width: 20
|
||||
auto_open_browser: true
|
||||
default_editor: "nvim"
|
||||
auto_open_browser: false
|
||||
default_port: 6420
|
||||
remote_operations: true
|
||||
auto_commit: false
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: TASK-70
|
||||
title: Polish YouTube subtitle generation pipeline
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-02-26 07:37'
|
||||
labels: []
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
$Current YouTube subtitle generation in launcher/youtube.ts is functional but has implicit behavior, low observability, and unnecessary full-file work. This task modernizes the existing pipeline without changing core architecture.
|
||||
|
||||
Scope:
|
||||
- Make track selection explicit (manual > auto > whisper per primary/secondary) with deterministic reasons.
|
||||
- Avoid running whisper/audio work when a track is already satisfied.
|
||||
- Add bounded execution for yt-dlp and whisper subprocesses.
|
||||
- Improve stage-level logging for both automatic and preprocess modes.
|
||||
- Make secondary track fallback decisions explicit and not implicit.
|
||||
- Preserve existing user behavior except where policy is clarified.
|
||||
|
||||
Files expected:
|
||||
- launcher/youtube.ts
|
||||
- launcher/commands/playback-command.ts (if mode/status behavior requires)
|
||||
- launcher/types.ts (if schema updates needed)
|
||||
- launcher/config/args-normalizer.ts (if timeout/config options added)
|
||||
- launcher/util.ts (if runExternalCommand timeout controls added)
|
||||
- Add/update launcher subtitle-generation tests
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Define deterministic track priority for each track: manual, then auto, then whisper (per track) and record source choice with reason.
|
||||
- [ ] #2 If manual or auto satisfies a track, skip whisper for that same track and avoid unrelated full extraction/transcription work.
|
||||
- [ ] #3 Introduce timeout or budget caps for yt-dlp and whisper calls; timeout should fail safe and unblock automatic playback.
|
||||
- [ ] #4 Emit explicit status logs at each stage: metadata load, manual sub fetch, auto sub fetch, whisper audio extraction, whisper run, publish/load, final success/failure summary.
|
||||
- [ ] #5 Make secondary handling explicit: transcribe target and translate target must only run when required by config and not by side-effect of primary logic.
|
||||
- [ ] #6 Keep preprocess and automatic modes stable in success paths while making behavior in failure paths explicit and bounded.
|
||||
- [ ] #7 Add tests for track-combination cases: primary available, secondary available, both missing, partial fallback, both missing with missing whisper config, timeout/error behavior.
|
||||
- [ ] #8 Document any behavior changes if user-visible, especially fallback order, timeout behavior, and fallback disablement.
|
||||
<!-- AC:END -->
|
||||
@@ -37,6 +37,8 @@ export default {
|
||||
],
|
||||
appearance: 'dark',
|
||||
cleanUrls: true,
|
||||
metaChunk: true,
|
||||
sitemap: { hostname: 'https://docs.subminer.moe' },
|
||||
lastUpdated: true,
|
||||
srcExclude: ['subagents/**'],
|
||||
markdown: {
|
||||
@@ -94,6 +96,18 @@ export default {
|
||||
search: {
|
||||
provider: 'local',
|
||||
},
|
||||
footer: {
|
||||
message: 'Released under the GPL-3.0 License.',
|
||||
copyright: 'Copyright © 2026-present sudacode',
|
||||
},
|
||||
editLink: {
|
||||
pattern: 'https://github.com/ksyasuda/SubMiner/edit/main/docs/:path',
|
||||
text: 'Edit this page on GitHub',
|
||||
},
|
||||
outline: { level: [2, 3], label: 'On this page' },
|
||||
externalLinkIcon: true,
|
||||
docFooter: { prev: 'Previous', next: 'Next' },
|
||||
returnToTopLabel: 'Back to top',
|
||||
socialLinks: [{ icon: 'github', link: 'https://github.com/ksyasuda/SubMiner' }],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -450,6 +450,8 @@ Setup flow details:
|
||||
2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`) to trigger setup.
|
||||
3. Approve access in AniList.
|
||||
4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically.
|
||||
- Encryption backend: Linux defaults to `gnome-libsecret`.
|
||||
Override with `--password-store=<backend>` (for example `--password-store=basic_text`).
|
||||
|
||||
Token + detection notes:
|
||||
|
||||
@@ -506,6 +508,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
|
||||
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
||||
|
||||
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup.
|
||||
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
|
||||
|
||||
Launcher subcommands:
|
||||
|
||||
@@ -514,6 +517,7 @@ Launcher subcommands:
|
||||
- `subminer jellyfin --logout` clears stored credentials.
|
||||
- `subminer jellyfin -p` opens play picker.
|
||||
- `subminer jellyfin -d` starts cast discovery mode.
|
||||
- These launcher commands also accept `--password-store=<backend>` to override the launcher-app forwarded Electron switch.
|
||||
|
||||
See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide.
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ SubMiner includes an optional Jellyfin CLI integration for:
|
||||
|
||||
- Jellyfin server URL and user credentials
|
||||
- For `--jellyfin-play`: connected mpv IPC socket (`--start` or existing mpv plugin workflow)
|
||||
- On Linux, token encryption defaults to `gnome-libsecret`; pass `--password-store=<backend>` to override.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -150,6 +151,7 @@ User-visible errors are shown through CLI logs and mpv OSD for:
|
||||
## Security Notes and Limitations
|
||||
|
||||
- Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted token storage after login/setup.
|
||||
- Launcher wrappers support `--password-store=<backend>` and forward it through to the app process.
|
||||
- Optional environment overrides are supported: `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID`.
|
||||
- Treat both token storage and config files as secrets and avoid committing them.
|
||||
- Password is used only for login and is not stored.
|
||||
|
||||
@@ -94,6 +94,9 @@ SubMiner.AppImage --help # Show all options
|
||||
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
|
||||
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop`.
|
||||
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
||||
- On Linux, the app now defaults `safeStorage` to `gnome-libsecret` for encrypted token persistence.
|
||||
Launcher pass-through commands also support `--password-store=<backend>` and forward it to the app when present.
|
||||
Override with e.g. `--password-store=basic_text`.
|
||||
- Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug`.
|
||||
|
||||
### Launcher Subcommands
|
||||
|
||||
@@ -10,9 +10,16 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
||||
return false;
|
||||
}
|
||||
|
||||
const appendPasswordStore = (forwarded: string[]): void => {
|
||||
if (args.passwordStore) {
|
||||
forwarded.push('--password-store', args.passwordStore);
|
||||
}
|
||||
};
|
||||
|
||||
if (args.jellyfin) {
|
||||
const forwarded = ['--jellyfin'];
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
appendPasswordStore(forwarded);
|
||||
runAppCommandWithInherit(appPath, forwarded);
|
||||
}
|
||||
|
||||
@@ -35,12 +42,14 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
||||
password,
|
||||
];
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
appendPasswordStore(forwarded);
|
||||
runAppCommandWithInherit(appPath, forwarded);
|
||||
}
|
||||
|
||||
if (args.jellyfinLogout) {
|
||||
const forwarded = ['--jellyfin-logout'];
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
appendPasswordStore(forwarded);
|
||||
runAppCommandWithInherit(appPath, forwarded);
|
||||
}
|
||||
|
||||
@@ -58,6 +67,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
||||
if (args.jellyfinDiscovery) {
|
||||
const forwarded = ['--start'];
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
appendPasswordStore(forwarded);
|
||||
runAppCommandWithInherit(appPath, forwarded);
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
||||
texthookerOnly: false,
|
||||
useRofi: false,
|
||||
logLevel: 'info',
|
||||
passwordStore: '',
|
||||
target: '',
|
||||
targetKind: '',
|
||||
};
|
||||
@@ -161,6 +162,7 @@ export function applyRootOptionsToArgs(
|
||||
if (typeof options.profile === 'string') parsed.profile = options.profile;
|
||||
if (options.start === true) parsed.startOverlay = true;
|
||||
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
|
||||
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
|
||||
if (options.rofi === true) parsed.useRofi = true;
|
||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||
@@ -175,6 +177,9 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
||||
if (invocations.jellyfinInvocation.logLevel) {
|
||||
parsed.logLevel = parseLogLevel(invocations.jellyfinInvocation.logLevel);
|
||||
}
|
||||
if (typeof invocations.jellyfinInvocation.passwordStore === 'string') {
|
||||
parsed.passwordStore = invocations.jellyfinInvocation.passwordStore;
|
||||
}
|
||||
const action = (invocations.jellyfinInvocation.action || '').toLowerCase();
|
||||
if (action && !['setup', 'discovery', 'play', 'login', 'logout'].includes(action)) {
|
||||
fail(`Unknown jellyfin action: ${invocations.jellyfinInvocation.action}`);
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface JellyfinInvocation {
|
||||
server?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
passwordStore?: string;
|
||||
logLevel?: string;
|
||||
}
|
||||
|
||||
@@ -168,6 +169,7 @@ export function parseCliPrograms(
|
||||
.option('-s, --server <url>', 'Jellyfin server URL')
|
||||
.option('-u, --username <name>', 'Jellyfin username')
|
||||
.option('-w, --password <pass>', 'Jellyfin password')
|
||||
.option('--password-store <backend>', 'Pass through Electron safeStorage backend')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||
jellyfinInvocation = {
|
||||
@@ -180,6 +182,7 @@ export function parseCliPrograms(
|
||||
server: typeof options.server === 'string' ? options.server : undefined,
|
||||
username: typeof options.username === 'string' ? options.username : undefined,
|
||||
password: typeof options.password === 'string' ? options.password : undefined,
|
||||
passwordStore: typeof options.passwordStore === 'string' ? options.passwordStore : undefined,
|
||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -395,5 +395,6 @@ export async function runJellyfinPlayMenu(
|
||||
}
|
||||
const forwarded = ['--start', '--jellyfin-play', '--jellyfin-item-id', itemId];
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
|
||||
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
||||
}
|
||||
|
||||
@@ -207,3 +207,33 @@ test('jellyfin login routes credentials to app command', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('jellyfin setup forwards password-store to app command', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher(
|
||||
['jf', 'setup', '--password-store', 'gnome-libsecret'],
|
||||
env,
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(
|
||||
fs.readFileSync(capturePath, 'utf8'),
|
||||
'--jellyfin\n--password-store\ngnome-libsecret\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,17 @@ test('parseArgs maps jellyfin play action and log-level override', () => {
|
||||
assert.equal(parsed.logLevel, 'debug');
|
||||
});
|
||||
|
||||
test('parseArgs forwards jellyfin password-store option', () => {
|
||||
const parsed = parseArgs(
|
||||
['jf', 'setup', '--password-store', 'gnome-libsecret'],
|
||||
'subminer',
|
||||
{},
|
||||
);
|
||||
|
||||
assert.equal(parsed.jellyfin, true);
|
||||
assert.equal(parsed.passwordStore, 'gnome-libsecret');
|
||||
});
|
||||
|
||||
test('parseArgs maps config show action', () => {
|
||||
const parsed = parseArgs(['config', 'show'], 'subminer', {});
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface Args {
|
||||
texthookerOnly: boolean;
|
||||
useRofi: boolean;
|
||||
logLevel: LogLevel;
|
||||
passwordStore: string;
|
||||
target: string;
|
||||
targetKind: '' | 'file' | 'url';
|
||||
jimakuApiKey: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "subminer",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
|
||||
@@ -30,10 +30,18 @@ function createStorage(encryptionAvailable: boolean): SafeStorageLike {
|
||||
};
|
||||
}
|
||||
|
||||
function createPassthroughStorage(): SafeStorageLike {
|
||||
return {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value: string) => Buffer.from(value, 'utf-8'),
|
||||
decryptString: (value: Buffer) => value.toString('utf-8'),
|
||||
};
|
||||
}
|
||||
|
||||
test('anilist token store saves and loads encrypted token', () => {
|
||||
const filePath = createTempTokenFile();
|
||||
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
|
||||
store.saveToken(' demo-token ');
|
||||
assert.equal(store.saveToken(' demo-token '), true);
|
||||
|
||||
const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
|
||||
encryptedToken?: string;
|
||||
@@ -44,16 +52,13 @@ test('anilist token store saves and loads encrypted token', () => {
|
||||
assert.equal(store.loadToken(), 'demo-token');
|
||||
});
|
||||
|
||||
test('anilist token store falls back to plaintext when encryption unavailable', () => {
|
||||
test('anilist token store refuses to persist token when encryption unavailable', () => {
|
||||
const filePath = createTempTokenFile();
|
||||
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(false));
|
||||
store.saveToken('plain-token');
|
||||
assert.equal(store.saveToken('plain-token'), false);
|
||||
|
||||
const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
|
||||
plaintextToken?: string;
|
||||
};
|
||||
assert.equal(payload.plaintextToken, 'plain-token');
|
||||
assert.equal(store.loadToken(), 'plain-token');
|
||||
assert.equal(fs.existsSync(filePath), false);
|
||||
assert.equal(store.loadToken(), null);
|
||||
});
|
||||
|
||||
test('anilist token store migrates legacy plaintext to encrypted', () => {
|
||||
@@ -75,6 +80,13 @@ test('anilist token store migrates legacy plaintext to encrypted', () => {
|
||||
assert.equal(payload.plaintextToken, undefined);
|
||||
});
|
||||
|
||||
test('anilist token store refuses passthrough safeStorage implementation', () => {
|
||||
const filePath = createTempTokenFile();
|
||||
const store = createAnilistTokenStore(filePath, createLogger(), createPassthroughStorage());
|
||||
assert.equal(store.saveToken('demo-token'), false);
|
||||
assert.equal(store.loadToken(), null);
|
||||
});
|
||||
|
||||
test('anilist token store clears persisted token file', () => {
|
||||
const filePath = createTempTokenFile();
|
||||
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
|
||||
|
||||
@@ -10,7 +10,7 @@ interface PersistedTokenPayload {
|
||||
|
||||
export interface AnilistTokenStore {
|
||||
loadToken: () => string | null;
|
||||
saveToken: (token: string) => void;
|
||||
saveToken: (token: string) => boolean;
|
||||
clearToken: () => void;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface SafeStorageLike {
|
||||
isEncryptionAvailable: () => boolean;
|
||||
encryptString: (value: string) => Buffer;
|
||||
decryptString: (value: Buffer) => string;
|
||||
getSelectedStorageBackend?: () => string;
|
||||
}
|
||||
|
||||
function ensureDirectory(filePath: string): void {
|
||||
@@ -38,9 +39,80 @@ export function createAnilistTokenStore(
|
||||
info: (message: string) => void;
|
||||
warn: (message: string, details?: unknown) => void;
|
||||
error: (message: string, details?: unknown) => void;
|
||||
warnUser?: (message: string) => void;
|
||||
},
|
||||
storage: SafeStorageLike = electron.safeStorage,
|
||||
): AnilistTokenStore {
|
||||
let safeStorageUsable: boolean | null = null;
|
||||
|
||||
const getSelectedBackend = (): string => {
|
||||
if (typeof storage.getSelectedStorageBackend !== 'function') {
|
||||
return 'unsupported';
|
||||
}
|
||||
try {
|
||||
return storage.getSelectedStorageBackend();
|
||||
} catch {
|
||||
return 'error';
|
||||
}
|
||||
};
|
||||
|
||||
const getSafeStorageDebugContext = (): string =>
|
||||
JSON.stringify({
|
||||
platform: process.platform,
|
||||
dbusSession: process.env.DBUS_SESSION_BUS_ADDRESS,
|
||||
xdgRuntimeDir: process.env.XDG_RUNTIME_DIR,
|
||||
display: process.env.DISPLAY,
|
||||
waylandDisplay: process.env.WAYLAND_DISPLAY,
|
||||
hasDefaultApp: Boolean(process.defaultApp),
|
||||
selectedSafeStorageBackend: getSelectedBackend(),
|
||||
});
|
||||
|
||||
const isSafeStorageUsable = (): boolean => {
|
||||
if (safeStorageUsable != null) return safeStorageUsable;
|
||||
|
||||
try {
|
||||
if (!storage.isEncryptionAvailable()) {
|
||||
notifyUser(
|
||||
`AniList token encryption unavailable: safeStorage.isEncryptionAvailable() is false. ` +
|
||||
`Context: ${getSafeStorageDebugContext()}`,
|
||||
);
|
||||
safeStorageUsable = false;
|
||||
return false;
|
||||
}
|
||||
const probe = storage.encryptString('__subminer_anilist_probe__');
|
||||
if (probe.equals(Buffer.from('__subminer_anilist_probe__'))) {
|
||||
notifyUser(
|
||||
'AniList token encryption probe failed: safeStorage.encryptString() returned plaintext bytes.',
|
||||
);
|
||||
safeStorageUsable = false;
|
||||
return false;
|
||||
}
|
||||
const roundTrip = storage.decryptString(probe);
|
||||
if (roundTrip !== '__subminer_anilist_probe__') {
|
||||
notifyUser(
|
||||
'AniList token encryption probe failed: encrypt/decrypt round trip returned unexpected content.',
|
||||
);
|
||||
safeStorageUsable = false;
|
||||
return false;
|
||||
}
|
||||
safeStorageUsable = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('AniList token encryption probe failed.', error);
|
||||
notifyUser(
|
||||
`AniList token encryption unavailable: safeStorage probe threw an error. ` +
|
||||
`Context: ${getSafeStorageDebugContext()}`,
|
||||
);
|
||||
safeStorageUsable = false;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const notifyUser = (message: string): void => {
|
||||
logger.warn(message);
|
||||
logger.warnUser?.(message);
|
||||
};
|
||||
|
||||
return {
|
||||
loadToken(): string | null {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
@@ -51,47 +123,62 @@ export function createAnilistTokenStore(
|
||||
const parsed = JSON.parse(raw) as PersistedTokenPayload;
|
||||
if (typeof parsed.encryptedToken === 'string' && parsed.encryptedToken.length > 0) {
|
||||
const encrypted = Buffer.from(parsed.encryptedToken, 'base64');
|
||||
if (!storage.isEncryptionAvailable()) {
|
||||
logger.warn('AniList token encryption is not available on this system.');
|
||||
if (!isSafeStorageUsable()) {
|
||||
return null;
|
||||
}
|
||||
const decrypted = storage.decryptString(encrypted).trim();
|
||||
return decrypted.length > 0 ? decrypted : null;
|
||||
if (decrypted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
if (
|
||||
typeof parsed.plaintextToken === 'string' &&
|
||||
parsed.plaintextToken.trim().length > 0
|
||||
) {
|
||||
if (storage.isEncryptionAvailable()) {
|
||||
if (!isSafeStorageUsable()) {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed.plaintextToken === 'string' && parsed.plaintextToken.trim().length > 0) {
|
||||
// Legacy fallback: migrate plaintext token to encrypted storage on load.
|
||||
const plaintext = parsed.plaintextToken.trim();
|
||||
notifyUser('AniList token plaintext fallback payload found. Migrating to encrypted storage.');
|
||||
this.saveToken(plaintext);
|
||||
return plaintext;
|
||||
}
|
||||
notifyUser(
|
||||
'AniList token plaintext was found but ignored because safe storage is unavailable.',
|
||||
);
|
||||
this.clearToken();
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to read AniList token store.', error);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
saveToken(token: string): void {
|
||||
saveToken(token: string): boolean {
|
||||
const trimmed = token.trim();
|
||||
if (trimmed.length === 0) {
|
||||
this.clearToken();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!storage.isEncryptionAvailable()) {
|
||||
logger.warn('AniList token encryption unavailable; storing token in plaintext fallback.');
|
||||
writePayload(filePath, {
|
||||
plaintextToken: trimmed,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return;
|
||||
if (!isSafeStorageUsable()) {
|
||||
notifyUser(
|
||||
'AniList token encryption is unavailable; refusing to store access token. Re-login required after restart.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const encrypted = storage.encryptString(trimmed);
|
||||
writePayload(filePath, {
|
||||
encryptedToken: encrypted.toString('base64'),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist AniList token.', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
50
src/main.ts
50
src/main.ts
@@ -30,6 +30,41 @@ import {
|
||||
screen,
|
||||
} from 'electron';
|
||||
|
||||
function getPasswordStoreArg(argv: string[]): string | null {
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg?.startsWith('--password-store')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--password-store') {
|
||||
const value = argv[i + 1];
|
||||
if (value && !value.startsWith('--')) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const [prefix, value] = arg.split('=', 2);
|
||||
if (prefix === '--password-store' && value && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePasswordStoreArg(value: string): string {
|
||||
const normalized = value.trim();
|
||||
if (normalized.toLowerCase() === 'gnome') {
|
||||
return 'gnome-libsecret';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getDefaultPasswordStore(): string {
|
||||
return 'gnome-libsecret';
|
||||
}
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'chrome-extension',
|
||||
@@ -400,6 +435,9 @@ import { resolveConfigDir } from './config/path-resolution';
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
||||
const passwordStore = normalizePasswordStoreArg(getPasswordStoreArg(process.argv) ?? getDefaultPasswordStore());
|
||||
app.commandLine.appendSwitch('password-store', passwordStore);
|
||||
console.debug(`[main] Applied --password-store ${passwordStore}`);
|
||||
}
|
||||
|
||||
app.setName('SubMiner');
|
||||
@@ -447,6 +485,7 @@ let jellyfinRemoteLastProgressAtMs = 0;
|
||||
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
|
||||
let backgroundWarmupsStarted = false;
|
||||
let yomitanLoadInFlight: Promise<Extension | null> | null = null;
|
||||
let notifyAnilistTokenStoreWarning: (message: string) => void = () => {};
|
||||
|
||||
const buildApplyJellyfinMpvDefaultsMainDepsHandler =
|
||||
createBuildApplyJellyfinMpvDefaultsMainDepsHandler({
|
||||
@@ -496,6 +535,7 @@ const anilistTokenStore = createAnilistTokenStore(
|
||||
info: (message: string) => console.info(message),
|
||||
warn: (message: string, details?: unknown) => console.warn(message, details),
|
||||
error: (message: string, details?: unknown) => console.error(message, details),
|
||||
warnUser: (message: string) => notifyAnilistTokenStoreWarning(message),
|
||||
},
|
||||
);
|
||||
const jellyfinTokenStore = createJellyfinTokenStore(
|
||||
@@ -518,6 +558,16 @@ const isDev = process.argv.includes('--dev') || process.argv.includes('--debug')
|
||||
const texthookerService = new Texthooker();
|
||||
const subtitleWsService = new SubtitleWebSocket();
|
||||
const logger = createLogger('main');
|
||||
notifyAnilistTokenStoreWarning = (message: string) => {
|
||||
logger.warn(`[AniList] ${message}`);
|
||||
try {
|
||||
showDesktopNotification('SubMiner AniList', {
|
||||
body: message,
|
||||
});
|
||||
} catch {
|
||||
// Notification may fail if desktop notifications are unavailable early in startup.
|
||||
}
|
||||
};
|
||||
const appLogger = {
|
||||
logInfo: (message: string) => {
|
||||
logger.info(message);
|
||||
|
||||
Reference in New Issue
Block a user