mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 07:21:33 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af67c53dd6 | |||
|
ea79e331fa
|
|||
| ee89b0c8a9 | |||
| f2fd58cd2b |
@@ -31,6 +31,6 @@ If docs-site/ changed, also: bun run docs:test && bun run docs:build
|
|||||||
|
|
||||||
## Checklist
|
## 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
|
- [ ] Docs updated in the same PR if behavior, defaults, flags, shortcuts, ports, or APIs changed
|
||||||
- [ ] Relevant checks pass locally (typecheck, tests, build)
|
- [ ] Relevant checks pass locally (typecheck, tests, build)
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ Start here, then leave this file.
|
|||||||
|
|
||||||
## Release / PR Notes
|
## 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
|
- User-visible docs changes get a `type: docs` fragment
|
||||||
- CI enforces `bun run changelog:lint` and `bun run changelog:pr-check`
|
- CI enforces `bun run changelog:lint` and `bun run changelog:pr-check`
|
||||||
- PR review helpers:
|
- PR review helpers:
|
||||||
|
|||||||
@@ -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
|
APP_NAME := subminer
|
||||||
THEME_SOURCE := assets/themes/subminer.rasi
|
THEME_SOURCE := assets/themes/subminer.rasi
|
||||||
@@ -72,7 +72,8 @@ help:
|
|||||||
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
|
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
|
||||||
"" \
|
"" \
|
||||||
"Other targets:" \
|
"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-linux Remove Linux install artifacts" \
|
||||||
" uninstall-macos Remove macOS install artifacts" \
|
" uninstall-macos Remove macOS install artifacts" \
|
||||||
" uninstall-windows Remove Windows mpv plugin artifacts" \
|
" uninstall-windows Remove Windows mpv plugin artifacts" \
|
||||||
@@ -105,8 +106,10 @@ print-dirs:
|
|||||||
"MACOS_APP_SRC=$(MACOS_APP_SRC)" \
|
"MACOS_APP_SRC=$(MACOS_APP_SRC)" \
|
||||||
"MACOS_ZIP_SRC=$(MACOS_ZIP_SRC)"
|
"MACOS_ZIP_SRC=$(MACOS_ZIP_SRC)"
|
||||||
|
|
||||||
deps:
|
submodules:
|
||||||
@$(MAKE) --no-print-directory ensure-bun
|
@git submodule update --init --recursive
|
||||||
|
|
||||||
|
deps: submodules ensure-bun
|
||||||
@bun install
|
@bun install
|
||||||
@cd stats && bun install --frozen-lockfile
|
@cd stats && bun install --frozen-lockfile
|
||||||
@cd vendor/texthooker-ui && 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.
|
||||||
@@ -31,6 +31,13 @@ Rules:
|
|||||||
- `README.md` is ignored by the generator
|
- `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
|
- 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:
|
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.
|
- 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.
|
||||||
@@ -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.
|
||||||
@@ -11,15 +11,10 @@ For internal architecture/workflow guidance, use `docs/README.md` at the repo ro
|
|||||||
```bash
|
```bash
|
||||||
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
||||||
cd SubMiner
|
cd SubMiner
|
||||||
# if you cloned without --recurse-submodules:
|
make deps
|
||||||
git submodule update --init --recursive
|
|
||||||
|
|
||||||
bun install
|
|
||||||
(cd stats && bun install --frozen-lockfile)
|
|
||||||
(cd vendor/texthooker-ui && bun install --frozen-lockfile)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`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
|
## 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` | Build platform package for detected OS |
|
||||||
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
|
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
|
||||||
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
|
| `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 pretty` | Run scoped Prettier formatting for maintained source/config files |
|
||||||
| `make generate-config` | Generate default config from centralized registry |
|
| `make generate-config` | Generate default config from centralized registry |
|
||||||
| `make build-linux` | Convenience wrapper for Linux packaging |
|
| `make build-linux` | Convenience wrapper for Linux packaging |
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config
|
|||||||
```bash
|
```bash
|
||||||
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
||||||
cd SubMiner
|
cd SubMiner
|
||||||
bun install
|
make deps
|
||||||
bun run build
|
bun run build
|
||||||
|
|
||||||
# Optional: build AppImage
|
# Optional: build AppImage
|
||||||
@@ -202,7 +202,7 @@ Bundled Yomitan is built during `bun run build`.
|
|||||||
```bash
|
```bash
|
||||||
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
||||||
cd SubMiner
|
cd SubMiner
|
||||||
git submodule update --init --recursive
|
make deps
|
||||||
make build-macos
|
make build-macos
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -216,14 +216,14 @@ The built app will be in the `release` directory (`.dmg` and `.zip`). For unsign
|
|||||||
```powershell
|
```powershell
|
||||||
git clone https://github.com/ksyasuda/SubMiner.git
|
git clone https://github.com/ksyasuda/SubMiner.git
|
||||||
cd SubMiner
|
cd SubMiner
|
||||||
|
git submodule update --init --recursive
|
||||||
bun install
|
bun install
|
||||||
|
Set-Location stats
|
||||||
# Windows requires building texthooker-ui manually before the main build
|
bun install --frozen-lockfile
|
||||||
Set-Location vendor/texthooker-ui
|
Set-Location ../vendor/texthooker-ui
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun run build
|
bun run build
|
||||||
Set-Location ../..
|
Set-Location ../..
|
||||||
|
|
||||||
bun run build:win
|
bun run build:win
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ Notes:
|
|||||||
- `changelog:check` now rejects tag/package version mismatches.
|
- `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: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.
|
- `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 `## What’s 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -488,7 +488,7 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
|
|||||||
changedEntries: [{ path: 'src/main-entry.ts', status: 'M' }],
|
changedEntries: [{ path: 'src/main-entry.ts', status: 'M' }],
|
||||||
changedLabels: [],
|
changedLabels: [],
|
||||||
}),
|
}),
|
||||||
/requires a changelog fragment/,
|
/requires a reconciled changelog fragment/,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.doesNotThrow(() =>
|
assert.doesNotThrow(() =>
|
||||||
@@ -514,7 +514,7 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
|
|||||||
],
|
],
|
||||||
changedLabels: [],
|
changedLabels: [],
|
||||||
}),
|
}),
|
||||||
/requires a changelog fragment/,
|
/requires a reconciled changelog fragment/,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.doesNotThrow(() =>
|
assert.doesNotThrow(() =>
|
||||||
@@ -526,6 +526,27 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
|
|||||||
changedLabels: [],
|
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 () => {
|
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, /## What’s 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, /What’s 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 () => {
|
test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => {
|
||||||
const { writeChangelogArtifacts } = await loadModule();
|
const { writeChangelogArtifacts } = await loadModule();
|
||||||
const workspace = createWorkspace('reuse-existing-section');
|
const workspace = createWorkspace('reuse-existing-section');
|
||||||
|
|||||||
+174
-3
@@ -4,6 +4,20 @@ import { execFileSync } from 'node:child_process';
|
|||||||
|
|
||||||
type RunClaude = (input: string, args: string[]) => string;
|
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 = {
|
type ChangelogFsDeps = {
|
||||||
existsSync?: (candidate: string) => boolean;
|
existsSync?: (candidate: string) => boolean;
|
||||||
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
|
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
|
||||||
@@ -13,6 +27,7 @@ type ChangelogFsDeps = {
|
|||||||
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
|
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
|
||||||
log?: (message: string) => void;
|
log?: (message: string) => void;
|
||||||
runClaude?: RunClaude;
|
runClaude?: RunClaude;
|
||||||
|
resolveContributions?: ResolveContributions;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PolishMode = 'changelog' | 'release-notes';
|
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[] = ['## What’s 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(
|
function serializeFragmentsForPrompt(
|
||||||
fragments: ChangeFragment[],
|
fragments: ChangeFragment[],
|
||||||
mode: PolishMode,
|
mode: PolishMode,
|
||||||
@@ -473,6 +634,7 @@ function renderReleaseNotes(
|
|||||||
changes: string,
|
changes: string,
|
||||||
options?: {
|
options?: {
|
||||||
disclaimer?: string;
|
disclaimer?: string;
|
||||||
|
contributions?: Contribution[];
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
|
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`.',
|
'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.',
|
||||||
'',
|
'',
|
||||||
|
...renderContributorsSections(options?.contributions ?? []),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,6 +667,7 @@ function writeReleaseNotesFile(
|
|||||||
options?: {
|
options?: {
|
||||||
disclaimer?: string;
|
disclaimer?: string;
|
||||||
outputPath?: string;
|
outputPath?: string;
|
||||||
|
contributions?: Contribution[];
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
||||||
@@ -530,6 +694,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
|
|||||||
const version = resolveVersion(options ?? {});
|
const version = resolveVersion(options ?? {});
|
||||||
const date = resolveDate(options?.date);
|
const date = resolveDate(options?.date);
|
||||||
const fragments = readChangeFragments(cwd, options?.deps);
|
const fragments = readChangeFragments(cwd, options?.deps);
|
||||||
|
const contributions = resolveContributionsForFragments(fragments, cwd, options?.deps);
|
||||||
const existingChangelogPath = path.join(cwd, 'CHANGELOG.md');
|
const existingChangelogPath = path.join(cwd, 'CHANGELOG.md');
|
||||||
const existingChangelog = existsSync(existingChangelogPath)
|
const existingChangelog = existsSync(existingChangelogPath)
|
||||||
? readFileSync(existingChangelogPath, 'utf8')
|
? readFileSync(existingChangelogPath, 'utf8')
|
||||||
@@ -547,6 +712,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
|
|||||||
cwd,
|
cwd,
|
||||||
stripDetailsBlocks(existingReleaseSection),
|
stripDetailsBlocks(existingReleaseSection),
|
||||||
options?.deps,
|
options?.deps,
|
||||||
|
{ contributions },
|
||||||
);
|
);
|
||||||
log(`Generated ${releaseNotesPath}`);
|
log(`Generated ${releaseNotesPath}`);
|
||||||
|
|
||||||
@@ -572,7 +738,9 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
|
|||||||
date,
|
date,
|
||||||
deps: options?.deps,
|
deps: options?.deps,
|
||||||
});
|
});
|
||||||
const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps);
|
const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps, {
|
||||||
|
contributions,
|
||||||
|
});
|
||||||
log(`Generated ${releaseNotesPath}`);
|
log(`Generated ${releaseNotesPath}`);
|
||||||
|
|
||||||
for (const fragment of fragments) {
|
for (const fragment of fragments) {
|
||||||
@@ -661,14 +829,15 @@ export function verifyPullRequestChangelog(options: PullRequestChangelogOptions)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFragment = normalizedEntries.some(
|
const fragmentEntries = normalizedEntries.filter(
|
||||||
(entry) => entry.status !== 'D' && isFragmentPath(entry.path),
|
(entry) => entry.status !== 'D' && isFragmentPath(entry.path),
|
||||||
);
|
);
|
||||||
|
const hasFragment = fragmentEntries.length > 0;
|
||||||
const requiresFragment = normalizedEntries.some((entry) => !isIgnoredPullRequestPath(entry.path));
|
const requiresFragment = normalizedEntries.some((entry) => !isIgnoredPullRequestPath(entry.path));
|
||||||
|
|
||||||
if (requiresFragment && !hasFragment) {
|
if (requiresFragment && !hasFragment) {
|
||||||
throw new Error(
|
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,
|
existingReleaseNotes,
|
||||||
deps: options?.deps,
|
deps: options?.deps,
|
||||||
});
|
});
|
||||||
|
const contributions = resolveContributionsForFragments(fragments, cwd, options?.deps);
|
||||||
return writeReleaseNotesFile(cwd, changes, options?.deps, {
|
return writeReleaseNotesFile(cwd, changes, options?.deps, {
|
||||||
disclaimer:
|
disclaimer:
|
||||||
'> 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.',
|
||||||
outputPath: PRERELEASE_NOTES_PATH,
|
outputPath: PRERELEASE_NOTES_PATH,
|
||||||
|
contributions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,37 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
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', () => {
|
test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers', () => {
|
||||||
let activePlayback: unknown = null;
|
let activePlayback: unknown = null;
|
||||||
|
|||||||
@@ -153,6 +153,16 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{
|
|||||||
openJellyfinSetupWindow: ReturnType<typeof createOpenJellyfinSetupWindowHandler>;
|
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(
|
export function composeJellyfinRuntimeHandlers(
|
||||||
options: JellyfinRuntimeComposerOptions,
|
options: JellyfinRuntimeComposerOptions,
|
||||||
): JellyfinRuntimeComposerResult {
|
): JellyfinRuntimeComposerResult {
|
||||||
@@ -268,12 +278,19 @@ export function composeJellyfinRuntimeHandlers(
|
|||||||
const maybeFocusExistingJellyfinSetupWindow = createMaybeFocusExistingJellyfinSetupWindowHandler(
|
const maybeFocusExistingJellyfinSetupWindow = createMaybeFocusExistingJellyfinSetupWindowHandler(
|
||||||
options.maybeFocusExistingJellyfinSetupWindowMainDeps,
|
options.maybeFocusExistingJellyfinSetupWindowMainDeps,
|
||||||
);
|
);
|
||||||
|
const restartJellyfinRemoteSessionAfterSetupLogin =
|
||||||
|
createRestartJellyfinRemoteSessionAfterSetupLoginHandler({
|
||||||
|
getCurrentSession: () => options.startJellyfinRemoteSessionMainDeps.getCurrentSession(),
|
||||||
|
startJellyfinRemoteSession: (startOptions) => startJellyfinRemoteSession(startOptions),
|
||||||
|
});
|
||||||
const openJellyfinSetupWindow = createOpenJellyfinSetupWindowHandler(
|
const openJellyfinSetupWindow = createOpenJellyfinSetupWindowHandler(
|
||||||
createBuildOpenJellyfinSetupWindowMainDepsHandler({
|
createBuildOpenJellyfinSetupWindowMainDepsHandler({
|
||||||
...options.openJellyfinSetupWindowMainDeps,
|
...options.openJellyfinSetupWindowMainDeps,
|
||||||
maybeFocusExistingSetupWindow: maybeFocusExistingJellyfinSetupWindow,
|
maybeFocusExistingSetupWindow: maybeFocusExistingJellyfinSetupWindow,
|
||||||
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
|
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
|
||||||
getJellyfinClientInfo: () => getJellyfinClientInfo(),
|
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'),
|
clearStoredSession: () => calls.push('clear-session'),
|
||||||
patchJellyfinConfig: () => calls.push('patch'),
|
patchJellyfinConfig: () => calls.push('patch'),
|
||||||
persistAuthenticatedSession: () => calls.push('persist'),
|
persistAuthenticatedSession: () => calls.push('persist'),
|
||||||
|
restartRemoteSession: () => {
|
||||||
|
calls.push('restart-remote');
|
||||||
|
},
|
||||||
|
stopRemoteSession: () => calls.push('stop-remote'),
|
||||||
logInfo: (message) => calls.push(`info:${message}`),
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
logError: (message) => calls.push(`error:${message}`),
|
logError: (message) => calls.push(`error:${message}`),
|
||||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||||
@@ -95,6 +99,8 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
|
|||||||
},
|
},
|
||||||
deps.getJellyfinClientInfo(),
|
deps.getJellyfinClientInfo(),
|
||||||
);
|
);
|
||||||
|
await deps.restartRemoteSession?.();
|
||||||
|
deps.stopRemoteSession?.();
|
||||||
deps.logInfo('ok');
|
deps.logInfo('ok');
|
||||||
deps.logError('bad', null);
|
deps.logError('bad', null);
|
||||||
deps.showMpvOsd('toast');
|
deps.showMpvOsd('toast');
|
||||||
@@ -110,6 +116,8 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
|
|||||||
'clear-session',
|
'clear-session',
|
||||||
'patch',
|
'patch',
|
||||||
'persist',
|
'persist',
|
||||||
|
'restart-remote',
|
||||||
|
'stop-remote',
|
||||||
'info:ok',
|
'info:ok',
|
||||||
'error:bad',
|
'error:bad',
|
||||||
'osd:toast',
|
'osd:toast',
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export function createBuildOpenJellyfinSetupWindowMainDepsHandler(
|
|||||||
persistAuthenticatedSession: deps.persistAuthenticatedSession
|
persistAuthenticatedSession: deps.persistAuthenticatedSession
|
||||||
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
|
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
restartRemoteSession: deps.restartRemoteSession
|
||||||
|
? () => deps.restartRemoteSession?.()
|
||||||
|
: undefined,
|
||||||
|
stopRemoteSession: deps.stopRemoteSession ? () => deps.stopRemoteSession?.() : undefined,
|
||||||
logInfo: (message: string) => deps.logInfo(message),
|
logInfo: (message: string) => deps.logInfo(message),
|
||||||
logError: (message: string, error: unknown) => deps.logError(message, error),
|
logError: (message: string, error: unknown) => deps.logError(message, error),
|
||||||
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
|
|||||||
patchPayload = session;
|
patchPayload = session;
|
||||||
calls.push('patch');
|
calls.push('patch');
|
||||||
},
|
},
|
||||||
|
restartRemoteSession: async () => {
|
||||||
|
calls.push('restart-remote');
|
||||||
|
},
|
||||||
logInfo: () => calls.push('info'),
|
logInfo: () => calls.push('info'),
|
||||||
logError: () => calls.push('error'),
|
logError: () => calls.push('error'),
|
||||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||||
@@ -172,7 +175,14 @@ test('createHandleJellyfinSetupSubmissionHandler applies successful login', asyn
|
|||||||
'b',
|
'b',
|
||||||
);
|
);
|
||||||
assert.equal(handled, true);
|
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.equal(authPassword, 'b');
|
||||||
assert.deepEqual(savedSession, { accessToken: 'token', userId: 'uid' });
|
assert.deepEqual(savedSession, { accessToken: 'token', userId: 'uid' });
|
||||||
assert.deepEqual(patchPayload, {
|
assert.deepEqual(patchPayload, {
|
||||||
@@ -329,6 +339,7 @@ test('createHandleJellyfinSetupSubmissionHandler handles logout and done', async
|
|||||||
saveStoredSession: () => calls.push('save'),
|
saveStoredSession: () => calls.push('save'),
|
||||||
clearStoredSession: () => calls.push('clear'),
|
clearStoredSession: () => calls.push('clear'),
|
||||||
patchJellyfinConfig: () => calls.push('patch'),
|
patchJellyfinConfig: () => calls.push('patch'),
|
||||||
|
stopRemoteSession: () => calls.push('stop-remote'),
|
||||||
logInfo: (message) => calls.push(message),
|
logInfo: (message) => calls.push(message),
|
||||||
logError: () => calls.push('error'),
|
logError: () => calls.push('error'),
|
||||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
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.equal(await handler('subminer://jellyfin-setup?action=done'), true);
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'clear',
|
'clear',
|
||||||
|
'stop-remote',
|
||||||
'Cleared stored Jellyfin auth session.',
|
'Cleared stored Jellyfin auth session.',
|
||||||
'osd:Jellyfin logged out',
|
'osd:Jellyfin logged out',
|
||||||
'reload',
|
'reload',
|
||||||
|
|||||||
@@ -425,6 +425,8 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
|
|||||||
clearStoredSession: () => void;
|
clearStoredSession: () => void;
|
||||||
patchJellyfinConfig: (session: JellyfinSession) => void;
|
patchJellyfinConfig: (session: JellyfinSession) => void;
|
||||||
persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
|
persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
|
||||||
|
restartRemoteSession?: () => Promise<void> | void;
|
||||||
|
stopRemoteSession?: () => void;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
logError: (message: string, error: unknown) => void;
|
logError: (message: string, error: unknown) => void;
|
||||||
showMpvOsd: (message: string) => void;
|
showMpvOsd: (message: string) => void;
|
||||||
@@ -447,6 +449,7 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
|
|||||||
if (submission.action === 'logout') {
|
if (submission.action === 'logout') {
|
||||||
try {
|
try {
|
||||||
deps.clearStoredSession();
|
deps.clearStoredSession();
|
||||||
|
deps.stopRemoteSession?.();
|
||||||
deps.logInfo('Cleared stored Jellyfin auth session.');
|
deps.logInfo('Cleared stored Jellyfin auth session.');
|
||||||
deps.showMpvOsd('Jellyfin logged out');
|
deps.showMpvOsd('Jellyfin logged out');
|
||||||
deps.reloadSetupWindow({
|
deps.reloadSetupWindow({
|
||||||
@@ -491,6 +494,7 @@ export function createHandleJellyfinSetupSubmissionHandler(deps: {
|
|||||||
deps.saveStoredSession({ accessToken: session.accessToken, userId: session.userId });
|
deps.saveStoredSession({ accessToken: session.accessToken, userId: session.userId });
|
||||||
deps.patchJellyfinConfig(session);
|
deps.patchJellyfinConfig(session);
|
||||||
}
|
}
|
||||||
|
await deps.restartRemoteSession?.();
|
||||||
deps.logInfo(`Jellyfin setup saved for ${session.username}.`);
|
deps.logInfo(`Jellyfin setup saved for ${session.username}.`);
|
||||||
deps.showMpvOsd('Jellyfin login success');
|
deps.showMpvOsd('Jellyfin login success');
|
||||||
deps.reloadSetupWindow({
|
deps.reloadSetupWindow({
|
||||||
@@ -593,6 +597,8 @@ export function createOpenJellyfinSetupWindowHandler<
|
|||||||
clearStoredSession: () => void;
|
clearStoredSession: () => void;
|
||||||
patchJellyfinConfig: (session: JellyfinSession) => void;
|
patchJellyfinConfig: (session: JellyfinSession) => void;
|
||||||
persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
|
persistAuthenticatedSession?: (session: JellyfinSession, clientInfo: JellyfinClientInfo) => void;
|
||||||
|
restartRemoteSession?: () => Promise<void> | void;
|
||||||
|
stopRemoteSession?: () => void;
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
logError: (message: string, error: unknown) => void;
|
logError: (message: string, error: unknown) => void;
|
||||||
showMpvOsd: (message: string) => void;
|
showMpvOsd: (message: string) => void;
|
||||||
@@ -633,6 +639,10 @@ export function createOpenJellyfinSetupWindowHandler<
|
|||||||
persistAuthenticatedSession: deps.persistAuthenticatedSession
|
persistAuthenticatedSession: deps.persistAuthenticatedSession
|
||||||
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
|
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
restartRemoteSession: deps.restartRemoteSession
|
||||||
|
? () => deps.restartRemoteSession?.()
|
||||||
|
: undefined,
|
||||||
|
stopRemoteSession: deps.stopRemoteSession ? () => deps.stopRemoteSession?.() : undefined,
|
||||||
logInfo: (message) => deps.logInfo(message),
|
logInfo: (message) => deps.logInfo(message),
|
||||||
logError: (message, error) => deps.logError(message, error),
|
logError: (message, error) => deps.logError(message, error),
|
||||||
showMpvOsd: (message) => deps.showMpvOsd(message),
|
showMpvOsd: (message) => deps.showMpvOsd(message),
|
||||||
|
|||||||
Reference in New Issue
Block a user