Compare commits

..

4 Commits

Author SHA1 Message Date
Autumn (Bee) af67c53dd6 [codex] Restart Jellyfin remote session after setup login (#112) 2026-06-06 11:52:16 -07:00
sudacode ea79e331fa build: make deps initializes submodules before installing JS deps
- Add `submodules` target that runs `git submodule update --init --recursive`
- `deps` now depends on `submodules` so fresh checkouts work out of the box
- Update docs to replace manual install steps with `make deps`
- Fix Windows build-from-source steps to include stats and submodule init
2026-06-06 01:25:01 -07:00
sudacode ee89b0c8a9 feat(release): add contributor attribution to release notes (#114) 2026-06-06 01:07:47 -07:00
sudacode f2fd58cd2b docs(changelog): require reconciled fragments, not just new ones (#113) 2026-06-06 00:55:34 -07:00
19 changed files with 403 additions and 27 deletions
+1 -1
View File
@@ -31,6 +31,6 @@ If docs-site/ changed, also: bun run docs:test && bun run docs:build
## Checklist
- [ ] Added a changelog fragment, or this PR is labeled `skip-changelog` (see [`changes/README.md`](../changes/README.md))
- [ ] Reconciled current-outcome changelog fragment(s), or this PR is labeled `skip-changelog` (see [`changes/README.md`](../changes/README.md))
- [ ] Docs updated in the same PR if behavior, defaults, flags, shortcuts, ports, or APIs changed
- [ ] Relevant checks pass locally (typecheck, tests, build)
+1 -1
View File
@@ -68,7 +68,7 @@ Start here, then leave this file.
## Release / PR Notes
- User-visible PRs need one fragment in `changes/*.md` — format and rules in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; apply the `skip-changelog` label to opt out)
- User-visible PRs need reconciled current-outcome fragment(s) in `changes/*.md` — format and rules in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; inspect existing same-PR fragments, then update/remove stale bullets or add only genuinely separate outcomes; apply the `skip-changelog` label to opt out)
- User-visible docs changes get a `type: docs` fragment
- CI enforces `bun run changelog:lint` and `bun run changelog:pr-check`
- PR review helpers:
+7 -4
View File
@@ -1,4 +1,4 @@
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop docs-test docs-build docs-build-versioned docs-dev
.PHONY: help submodules deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop docs-test docs-build docs-build-versioned docs-dev
APP_NAME := subminer
THEME_SOURCE := assets/themes/subminer.rasi
@@ -72,7 +72,8 @@ help:
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
"" \
"Other targets:" \
" deps Install JS dependencies (root + stats + texthooker-ui)" \
" submodules Initialize/update git submodules" \
" deps Initialize submodules and install JS dependencies (root + stats + texthooker-ui)" \
" uninstall-linux Remove Linux install artifacts" \
" uninstall-macos Remove macOS install artifacts" \
" uninstall-windows Remove Windows mpv plugin artifacts" \
@@ -105,8 +106,10 @@ print-dirs:
"MACOS_APP_SRC=$(MACOS_APP_SRC)" \
"MACOS_ZIP_SRC=$(MACOS_ZIP_SRC)"
deps:
@$(MAKE) --no-print-directory ensure-bun
submodules:
@git submodule update --init --recursive
deps: submodules ensure-bun
@bun install
@cd stats && bun install --frozen-lockfile
@cd vendor/texthooker-ui && bun install --frozen-lockfile
@@ -0,0 +1,4 @@
type: added
area: release
- Release notes now credit contributors with a `What's Changed` list (`by @author in #pr`) and a `New Contributors` section for first-time authors, resolved from changelog fragments via git and the GitHub API.
+7
View File
@@ -31,6 +31,13 @@ Rules:
- `README.md` is ignored by the generator
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
PR branch workflow:
- Before adding a fragment or bullet, inspect the `changes/*.md` files already changed in the PR
- If the new work fixes, modifies, renames, or supersedes behavior introduced or referenced by that fragment, edit or remove the stale bullet instead of adding follow-up churn
- Add a new bullet only when it describes a truly separate user-visible outcome
- Multiple fragment files are allowed when one PR has genuinely separate release-note outcomes, but keep them minimized and current
How fragments turn into a release:
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
@@ -0,0 +1,4 @@
type: changed
area: release
- Changed PR changelog guidance to preserve multiple fragments for genuinely separate outcomes while directing contributors to update, remove, or merge same-PR fragment notes before adding follow-up churn.
@@ -0,0 +1,5 @@
type: fixed
area: jellyfin
- Restarted the Jellyfin remote session after successful setup login so websocket reconnects use the freshly saved credentials.
- Stopped the Jellyfin remote session on setup logout.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: build
- Updated `make deps` so a fresh source checkout initializes submodules before installing root, stats, and texthooker-ui dependencies.
+3 -8
View File
@@ -11,15 +11,10 @@ For internal architecture/workflow guidance, use `docs/README.md` at the repo ro
```bash
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
cd SubMiner
# if you cloned without --recurse-submodules:
git submodule update --init --recursive
bun install
(cd stats && bun install --frozen-lockfile)
(cd vendor/texthooker-ui && bun install --frozen-lockfile)
make deps
```
`make deps` is still available as a convenience wrapper around the same dependency install flow.
`make deps` initializes submodules and installs root, `stats/`, and `vendor/texthooker-ui` dependencies. The Yomitan submodule installs its own dependencies on demand during `bun run build`.
## Building
@@ -216,7 +211,7 @@ Run `make help` for a full list of targets. Key ones:
| `make build` | Build platform package for detected OS |
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
| `make deps` | Install JS dependencies (root + stats + texthooker-ui) |
| `make deps` | Init submodules and install root/stats/texthooker-ui deps |
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
| `make generate-config` | Generate default config from centralized registry |
| `make build-linux` | Convenience wrapper for Linux packaging |
+6 -6
View File
@@ -185,7 +185,7 @@ Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config
```bash
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
cd SubMiner
bun install
make deps
bun run build
# Optional: build AppImage
@@ -202,7 +202,7 @@ Bundled Yomitan is built during `bun run build`.
```bash
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
cd SubMiner
git submodule update --init --recursive
make deps
make build-macos
```
@@ -216,14 +216,14 @@ The built app will be in the `release` directory (`.dmg` and `.zip`). For unsign
```powershell
git clone https://github.com/ksyasuda/SubMiner.git
cd SubMiner
git submodule update --init --recursive
bun install
# Windows requires building texthooker-ui manually before the main build
Set-Location vendor/texthooker-ui
Set-Location stats
bun install --frozen-lockfile
Set-Location ../vendor/texthooker-ui
bun install --frozen-lockfile
bun run build
Set-Location ../..
bun run build:win
```
+1
View File
@@ -77,6 +77,7 @@ Notes:
- `changelog:check` now rejects tag/package version mismatches.
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
- `release/release-notes.md` (and `release/prerelease-notes.md`) end with GitHub-style attribution: a `## Whats Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
- Do not tag while `changes/*.md` fragments still exist.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
+102 -2
View File
@@ -488,7 +488,7 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
changedEntries: [{ path: 'src/main-entry.ts', status: 'M' }],
changedLabels: [],
}),
/requires a changelog fragment/,
/requires a reconciled changelog fragment/,
);
assert.doesNotThrow(() =>
@@ -514,7 +514,7 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
],
changedLabels: [],
}),
/requires a changelog fragment/,
/requires a reconciled changelog fragment/,
);
assert.doesNotThrow(() =>
@@ -526,6 +526,27 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
changedLabels: [],
}),
);
assert.doesNotThrow(() =>
verifyPullRequestChangelog({
changedEntries: [
{ path: 'src/main-entry.ts', status: 'M' },
{ path: 'changes/001.md', status: 'M' },
],
changedLabels: [],
}),
);
assert.doesNotThrow(() =>
verifyPullRequestChangelog({
changedEntries: [
{ path: 'src/main-entry.ts', status: 'M' },
{ path: 'changes/001.md', status: 'A' },
{ path: 'changes/002.md', status: 'A' },
],
changedLabels: [],
}),
);
});
test('writePrereleaseNotesForVersion writes cumulative beta notes without mutating stable changelog artifacts', async () => {
@@ -1044,6 +1065,85 @@ test('writeChangelogArtifacts filters internal fragments from the release-notes
}
});
test('writeChangelogArtifacts appends contributor attribution and a new-contributors section to release notes', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('release-notes-contributors');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- Added a feature.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '002.md'),
['type: fixed', 'area: jellyfin', '', '- Fixed a bug.'].join('\n'),
'utf8',
);
try {
const stub = defaultStubClaude();
const resolveContributionsCalls: string[][] = [];
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.6.0',
date: '2026-05-06',
deps: {
runClaude: stub.runClaude,
resolveContributions: (fragmentPaths) => {
resolveContributionsCalls.push(fragmentPaths);
return [
{
prNumber: 110,
login: 'ksyasuda',
title: 'feat(overlay): add a feature',
isFirstContribution: false,
},
{
prNumber: 112,
login: 'bee-san',
title: 'fix(jellyfin): restart remote session',
isFirstContribution: true,
},
];
},
},
});
assert.equal(resolveContributionsCalls.length, 1, 'resolves contributions once per release');
assert.deepEqual(resolveContributionsCalls[0], [
path.join(projectRoot, 'changes', '001.md'),
path.join(projectRoot, 'changes', '002.md'),
]);
const releaseNotes = fs.readFileSync(
path.join(projectRoot, 'release', 'release-notes.md'),
'utf8',
);
assert.match(releaseNotes, /## Whats Changed\n\n/);
assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
assert.match(
releaseNotes,
/## New Contributors\n\n- @bee-san made their first contribution in #112/,
);
assert.doesNotMatch(
releaseNotes,
/ksyasuda made their first contribution/,
'returning contributors are not listed under New Contributors',
);
// Attribution is a release-notes concern only; the CHANGELOG stays clean.
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
assert.doesNotMatch(changelog, /Whats Changed/);
assert.doesNotMatch(changelog, /New Contributors/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('reuse-existing-section');
+174 -3
View File
@@ -4,6 +4,20 @@ import { execFileSync } from 'node:child_process';
type RunClaude = (input: string, args: string[]) => string;
// A single PR's contribution, resolved from the fragment files released in this
// cycle. Used to append GitHub-style attribution to the release notes.
type Contribution = {
prNumber: number;
login: string;
title: string;
isFirstContribution: boolean;
};
// Resolves the contributions behind a set of changelog fragment paths. Injected
// in tests so we never hit git/gh; the default implementation walks git history
// and the GitHub API.
type ResolveContributions = (fragmentPaths: string[], cwd: string) => Contribution[];
type ChangelogFsDeps = {
existsSync?: (candidate: string) => boolean;
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
@@ -13,6 +27,7 @@ type ChangelogFsDeps = {
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
log?: (message: string) => void;
runClaude?: RunClaude;
resolveContributions?: ResolveContributions;
};
type PolishMode = 'changelog' | 'release-notes';
@@ -296,6 +311,152 @@ function defaultRunClaude(input: string, args: string[]): string {
}
}
function resolveFragmentRelativePath(fragmentPath: string, cwd: string): string {
return path.relative(cwd, fragmentPath).split(path.sep).join('/');
}
// Walks git history + the GitHub API to attribute each released fragment to the
// PR (and author) that introduced it. One git call and one gh call per fragment,
// plus one gh call per unique author for the first-contribution check. Best
// effort: if gh is unavailable/unauthenticated or any lookup fails, we warn and
// drop attribution rather than failing the release.
function defaultResolveContributions(fragmentPaths: string[], cwd: string): Contribution[] {
if (fragmentPaths.length === 0) {
return [];
}
try {
const slug = execFileSync(
'gh',
['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'],
{
cwd,
encoding: 'utf8',
},
).trim();
if (!slug) {
return [];
}
const byPr = new Map<number, Contribution>();
for (const fragmentPath of fragmentPaths) {
const relativePath = resolveFragmentRelativePath(fragmentPath, cwd);
// git log lists newest first, so the commit that *added* the file is the
// last line of the --diff-filter=A history.
const addingSha = execFileSync(
'git',
['log', '--diff-filter=A', '--follow', '--format=%H', '--', relativePath],
{ cwd, encoding: 'utf8' },
)
.trim()
.split(/\r?\n/)
.filter(Boolean)
.pop();
if (!addingSha) {
continue;
}
const prRaw = execFileSync(
'gh',
[
'api',
`repos/${slug}/commits/${addingSha}/pulls`,
'--jq',
'.[0] // empty | {number, login: .user.login, title}',
],
{ cwd, encoding: 'utf8' },
).trim();
if (!prRaw) {
continue;
}
const pr = JSON.parse(prRaw) as { number?: number; login?: string; title?: string };
if (typeof pr.number !== 'number' || !pr.login || !pr.title) {
continue;
}
if (!byPr.has(pr.number)) {
byPr.set(pr.number, {
prNumber: pr.number,
login: pr.login,
title: pr.title,
isFirstContribution: false,
});
}
}
const firstPrByAuthor = new Map<string, number | null>();
for (const contribution of byPr.values()) {
if (!firstPrByAuthor.has(contribution.login)) {
const firstRaw = execFileSync(
'gh',
[
'api',
'-X',
'GET',
'search/issues',
'-f',
`q=repo:${slug} is:pr is:merged author:${contribution.login}`,
'-f',
'sort=created',
'-f',
'order=asc',
'-f',
'per_page=1',
'--jq',
'.items[0].number // empty',
],
{ cwd, encoding: 'utf8' },
).trim();
firstPrByAuthor.set(contribution.login, firstRaw ? Number.parseInt(firstRaw, 10) : null);
}
const firstPr = firstPrByAuthor.get(contribution.login) ?? null;
contribution.isFirstContribution = firstPr !== null && firstPr === contribution.prNumber;
}
return [...byPr.values()].sort((a, b) => a.prNumber - b.prNumber);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`Skipping contributor attribution: ${message}`);
return [];
}
}
function resolveContributionsForFragments(
fragments: ChangeFragment[],
cwd: string,
deps?: ChangelogFsDeps,
): Contribution[] {
const resolve = deps?.resolveContributions ?? defaultResolveContributions;
return resolve(
fragments.filter((fragment) => fragment.type !== 'internal').map((fragment) => fragment.path),
cwd,
);
}
function renderContributorsSections(contributions: Contribution[]): string[] {
if (contributions.length === 0) {
return [];
}
const lines: string[] = ['## Whats Changed', ''];
for (const contribution of contributions) {
lines.push(`- ${contribution.title} by @${contribution.login} in #${contribution.prNumber}`);
}
const firstTimers = contributions.filter((contribution) => contribution.isFirstContribution);
if (firstTimers.length > 0) {
lines.push('', '## New Contributors', '');
for (const contribution of firstTimers) {
lines.push(
`- @${contribution.login} made their first contribution in #${contribution.prNumber}`,
);
}
}
lines.push('');
return lines;
}
function serializeFragmentsForPrompt(
fragments: ChangeFragment[],
mode: PolishMode,
@@ -473,6 +634,7 @@ function renderReleaseNotes(
changes: string,
options?: {
disclaimer?: string;
contributions?: Contribution[];
},
): string {
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
@@ -494,6 +656,7 @@ function renderReleaseNotes(
'',
'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.',
'',
...renderContributorsSections(options?.contributions ?? []),
].join('\n');
}
@@ -504,6 +667,7 @@ function writeReleaseNotesFile(
options?: {
disclaimer?: string;
outputPath?: string;
contributions?: Contribution[];
},
): string {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
@@ -530,6 +694,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
const version = resolveVersion(options ?? {});
const date = resolveDate(options?.date);
const fragments = readChangeFragments(cwd, options?.deps);
const contributions = resolveContributionsForFragments(fragments, cwd, options?.deps);
const existingChangelogPath = path.join(cwd, 'CHANGELOG.md');
const existingChangelog = existsSync(existingChangelogPath)
? readFileSync(existingChangelogPath, 'utf8')
@@ -547,6 +712,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
cwd,
stripDetailsBlocks(existingReleaseSection),
options?.deps,
{ contributions },
);
log(`Generated ${releaseNotesPath}`);
@@ -572,7 +738,9 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
date,
deps: options?.deps,
});
const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps);
const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps, {
contributions,
});
log(`Generated ${releaseNotesPath}`);
for (const fragment of fragments) {
@@ -661,14 +829,15 @@ export function verifyPullRequestChangelog(options: PullRequestChangelogOptions)
return;
}
const hasFragment = normalizedEntries.some(
const fragmentEntries = normalizedEntries.filter(
(entry) => entry.status !== 'D' && isFragmentPath(entry.path),
);
const hasFragment = fragmentEntries.length > 0;
const requiresFragment = normalizedEntries.some((entry) => !isIgnoredPullRequestPath(entry.path));
if (requiresFragment && !hasFragment) {
throw new Error(
`This pull request changes release-relevant files and requires a changelog fragment under changes/ or the ${SKIP_CHANGELOG_LABEL} label.`,
`This pull request changes release-relevant files and requires a reconciled changelog fragment under changes/ or the ${SKIP_CHANGELOG_LABEL} label. Before adding a new fragment, update the existing PR fragment when the new work modifies, fixes, or supersedes behavior already described there.`,
);
}
}
@@ -832,10 +1001,12 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
existingReleaseNotes,
deps: options?.deps,
});
const contributions = resolveContributionsForFragments(fragments, cwd, options?.deps);
return writeReleaseNotesFile(cwd, changes, options?.deps, {
disclaimer:
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
outputPath: PRERELEASE_NOTES_PATH,
contributions,
});
}
@@ -1,6 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeJellyfinRuntimeHandlers } from './jellyfin-runtime-composer';
import {
composeJellyfinRuntimeHandlers,
createRestartJellyfinRemoteSessionAfterSetupLoginHandler,
} from './jellyfin-runtime-composer';
test('setup login restart uses auto-connect path without an active remote session', async () => {
const startOptions: Array<{ explicit?: boolean } | undefined> = [];
const restart = createRestartJellyfinRemoteSessionAfterSetupLoginHandler({
getCurrentSession: () => null,
startJellyfinRemoteSession: async (options) => {
startOptions.push(options);
},
});
await restart();
assert.deepEqual(startOptions, [undefined]);
});
test('setup login restart explicitly refreshes an active remote session', async () => {
const startOptions: Array<{ explicit?: boolean } | undefined> = [];
const restart = createRestartJellyfinRemoteSessionAfterSetupLoginHandler({
getCurrentSession: () => ({ stop: () => {} }),
startJellyfinRemoteSession: async (options) => {
startOptions.push(options);
},
});
await restart();
assert.deepEqual(startOptions, [{ explicit: true }]);
});
test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers', () => {
let activePlayback: unknown = null;
@@ -153,6 +153,16 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{
openJellyfinSetupWindow: ReturnType<typeof createOpenJellyfinSetupWindowHandler>;
}>;
export function createRestartJellyfinRemoteSessionAfterSetupLoginHandler(deps: {
getCurrentSession: () => unknown | null;
startJellyfinRemoteSession: (options?: { explicit?: boolean }) => Promise<void>;
}) {
return async (): Promise<void> => {
const hasActiveSession = deps.getCurrentSession() !== null;
await deps.startJellyfinRemoteSession(hasActiveSession ? { explicit: true } : undefined);
};
}
export function composeJellyfinRuntimeHandlers(
options: JellyfinRuntimeComposerOptions,
): JellyfinRuntimeComposerResult {
@@ -268,12 +278,19 @@ export function composeJellyfinRuntimeHandlers(
const maybeFocusExistingJellyfinSetupWindow = createMaybeFocusExistingJellyfinSetupWindowHandler(
options.maybeFocusExistingJellyfinSetupWindowMainDeps,
);
const restartJellyfinRemoteSessionAfterSetupLogin =
createRestartJellyfinRemoteSessionAfterSetupLoginHandler({
getCurrentSession: () => options.startJellyfinRemoteSessionMainDeps.getCurrentSession(),
startJellyfinRemoteSession: (startOptions) => startJellyfinRemoteSession(startOptions),
});
const openJellyfinSetupWindow = createOpenJellyfinSetupWindowHandler(
createBuildOpenJellyfinSetupWindowMainDepsHandler({
...options.openJellyfinSetupWindowMainDeps,
maybeFocusExistingSetupWindow: maybeFocusExistingJellyfinSetupWindow,
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
getJellyfinClientInfo: () => getJellyfinClientInfo(),
restartRemoteSession: () => restartJellyfinRemoteSessionAfterSetupLogin(),
stopRemoteSession: () => stopJellyfinRemoteSession(),
})(),
);
@@ -40,6 +40,10 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
clearStoredSession: () => calls.push('clear-session'),
patchJellyfinConfig: () => calls.push('patch'),
persistAuthenticatedSession: () => calls.push('persist'),
restartRemoteSession: () => {
calls.push('restart-remote');
},
stopRemoteSession: () => calls.push('stop-remote'),
logInfo: (message) => calls.push(`info:${message}`),
logError: (message) => calls.push(`error:${message}`),
showMpvOsd: (message) => calls.push(`osd:${message}`),
@@ -95,6 +99,8 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
},
deps.getJellyfinClientInfo(),
);
await deps.restartRemoteSession?.();
deps.stopRemoteSession?.();
deps.logInfo('ok');
deps.logError('bad', null);
deps.showMpvOsd('toast');
@@ -110,6 +116,8 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
'clear-session',
'patch',
'persist',
'restart-remote',
'stop-remote',
'info:ok',
'error:bad',
'osd:toast',
@@ -20,6 +20,10 @@ export function createBuildOpenJellyfinSetupWindowMainDepsHandler(
persistAuthenticatedSession: deps.persistAuthenticatedSession
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
: undefined,
restartRemoteSession: deps.restartRemoteSession
? () => deps.restartRemoteSession?.()
: undefined,
stopRemoteSession: deps.stopRemoteSession ? () => deps.stopRemoteSession?.() : undefined,
logInfo: (message: string) => deps.logInfo(message),
logError: (message: string, error: unknown) => deps.logError(message, error),
showMpvOsd: (message: string) => deps.showMpvOsd(message),
+13 -1
View File
@@ -160,6 +160,9 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
patchPayload = session;
calls.push('patch');
},
restartRemoteSession: async () => {
calls.push('restart-remote');
},
logInfo: () => calls.push('info'),
logError: () => calls.push('error'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
@@ -172,7 +175,14 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
'b',
);
assert.equal(handled, true);
assert.deepEqual(calls, ['save', 'patch', 'info', 'osd:Jellyfin login success', 'reload']);
assert.deepEqual(calls, [
'save',
'patch',
'restart-remote',
'info',
'osd:Jellyfin login success',
'reload',
]);
assert.equal(authPassword, 'b');
assert.deepEqual(savedSession, { accessToken: 'token', userId: 'uid' });
assert.deepEqual(patchPayload, {
@@ -329,6 +339,7 @@ test('createHandleJellyfinSetupSubmissionHandler handles logout and done', async
saveStoredSession: () => calls.push('save'),
clearStoredSession: () => calls.push('clear'),
patchJellyfinConfig: () => calls.push('patch'),
stopRemoteSession: () => calls.push('stop-remote'),
logInfo: (message) => calls.push(message),
logError: () => calls.push('error'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
@@ -340,6 +351,7 @@ test('createHandleJellyfinSetupSubmissionHandler handles logout and done', async
assert.equal(await handler('subminer://jellyfin-setup?action=done'), true);
assert.deepEqual(calls, [
'clear',
'stop-remote',
'Cleared stored Jellyfin auth session.',
'osd:Jellyfin logged out',
'reload',
+10
View File
@@ -425,6 +425,8 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
clearStoredSession: () => void;
patchJellyfinConfig: (session: JellyfinSession) => void;
persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
restartRemoteSession?: () => Promise<void> | void;
stopRemoteSession?: () => void;
logInfo: (message: string) => void;
logError: (message: string, error: unknown) => void;
showMpvOsd: (message: string) => void;
@@ -447,6 +449,7 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
if (submission.action === 'logout') {
try {
deps.clearStoredSession();
deps.stopRemoteSession?.();
deps.logInfo('Cleared stored Jellyfin auth session.');
deps.showMpvOsd('Jellyfin logged out');
deps.reloadSetupWindow({
@@ -491,6 +494,7 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
deps.saveStoredSession({ accessToken: session.accessToken, userId: session.userId });
deps.patchJellyfinConfig(session);
}
await deps.restartRemoteSession?.();
deps.logInfo(`Jellyfin setup saved for ${session.username}.`);
deps.showMpvOsd('Jellyfin login success');
deps.reloadSetupWindow({
@@ -593,6 +597,8 @@ export function createOpenJellyfinSetupWindowHandler<
clearStoredSession: () => void;
patchJellyfinConfig: (session: JellyfinSession) => void;
persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
restartRemoteSession?: () => Promise<void> | void;
stopRemoteSession?: () => void;
logInfo: (message: string) => void;
logError: (message: string, error: unknown) => void;
showMpvOsd: (message: string) => void;
@@ -633,6 +639,10 @@ export function createOpenJellyfinSetupWindowHandler<
persistAuthenticatedSession: deps.persistAuthenticatedSession
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
: undefined,
restartRemoteSession: deps.restartRemoteSession
? () => deps.restartRemoteSession?.()
: undefined,
stopRemoteSession: deps.stopRemoteSession ? () => deps.stopRemoteSession?.() : undefined,
logInfo: (message) => deps.logInfo(message),
logError: (message, error) => deps.logError(message, error),
showMpvOsd: (message) => deps.showMpvOsd(message),