diff --git a/.githooks/pre-commit b/.githooks/pre-commit index fc5fdfd..68de32a 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -66,6 +66,7 @@ run_extra_dir() { echo "Running pre-commit checks ($mode_label)... ${REPO_ROOT}" >&2 python_changed=0 +frontend_changed=0 while IFS= read -r path; do [ -n "$path" ] || continue @@ -75,12 +76,22 @@ while IFS= read -r path; do ;; .editorconfig) python_changed=1 + frontend_changed=1 + ;; + ui/*) + frontend_changed=1 ;; esac done <&2 + NODE_ENV=development bun --cwd=./ui run nuxt prepare --envName=development + echo "Frontend preparation passed." >&2 +fi + if [ "$python_changed" -eq 1 ]; then run_step "Python: Formatter" uv run ruff format --check . run_step "Python: Linter" uv run ruff check . @@ -97,8 +108,21 @@ if [ "$python_changed" -eq 1 ]; then fi fi +if [ "$frontend_changed" -eq 1 ]; then + run_step "Frontend: Formatter" bun --cwd=./ui run format:check + run_step "Frontend: Linter" bun --cwd=./ui run lint:ci + run_step "Frontend: Tests" bun --cwd=./ui test + run_step "Frontend: TSC" bun --cwd=./ui run lint:tsc +fi + extra_dir=$(git rev-parse --git-path hooks)/pre-commit.d run_extra_dir "Global" "$extra_dir" +if [ "$python_changed" -eq 1 ]; then + run_extra_dir "Backend" "$extra_dir/backend" +fi +if [ "$frontend_changed" -eq 1 ]; then + run_extra_dir "Frontend" "$extra_dir/frontend" +fi if [ "$checks_started" -eq 0 ]; then echo "No checks apply to the $mode_label files." diff --git a/.github/scripts/generate_changelog.py b/.github/scripts/generate_changelog.py index eded7be..a3a2284 100644 --- a/.github/scripts/generate_changelog.py +++ b/.github/scripts/generate_changelog.py @@ -9,6 +9,8 @@ from pathlib import Path from typing import TypedDict +CO_AUTHORED_BY = re.compile(r"^Co-authored-by:\s", re.IGNORECASE) + class Commit(TypedDict): sha: str @@ -29,34 +31,46 @@ def git(repo: Path, *args: str) -> str: return subprocess.check_output(["git", "-C", str(repo), *args], text=True, encoding="utf-8") # noqa: S607 -def commits_between(repo: Path, end: str, excluded: list[str]) -> list[Commit]: +def commits_between(repo: Path, refs: list[str], excluded: list[str]) -> list[Commit]: output = git( - repo, "log", "--topo-order", "--no-merges", "-z", "--format=%H%x00%an%x00%cI%x00%B", end, "--not", *excluded + repo, "log", "--topo-order", "--no-merges", "-z", "--format=%H%x00%an%x00%cI%x00%B", *refs, "--not", *excluded ) fields = output.split("\0") return [ - Commit(sha=sha[:8], full_sha=sha, author=author, date=date, message=message.strip()) + Commit( + sha=sha[:8], + full_sha=sha, + author=author, + date=date, + message="\n".join(line for line in message.splitlines() if not CO_AUTHORED_BY.match(line)).strip(), + ) for sha, author, date, message in zip(fields[0::4], fields[1::4], fields[2::4], fields[3::4], strict=False) ] -def generate_changelog(repo: Path) -> list[ChangeSet]: +def generate_changelog(repo: Path, *, ref: str = "HEAD", include_ref: str | None = None) -> list[ChangeSet]: if git(repo, "rev-parse", "--is-shallow-repository").strip() == "true": msg = "Cannot generate complete commit history from a shallow checkout. Fetch full history and tags first." raise RuntimeError(msg) - history = git(repo, "rev-list", "--topo-order", "HEAD").splitlines() + history = git(repo, "rev-list", "--topo-order", ref).splitlines() positions = {sha: index for index, sha in enumerate(history)} tags = [ (name, git(repo, "rev-parse", f"refs/tags/{name}^{{commit}}").strip()) - for name in git(repo, "tag", "--merged", "HEAD").splitlines() + for name in git(repo, "tag", "--merged", history[0]).splitlines() if re.fullmatch(r"v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?", name) ] # Commit timestamps can move backwards. Release ranges follow ancestry instead. tags.sort(key=lambda tag: (positions[tag[1]], tag[0])) + unreleased_refs = [history[0]] + if ( + include_ref is not None + and git(repo, "rev-list", "--max-count=1", "--no-merges", include_ref, "--not", history[0]).strip() + ): + unreleased_refs.insert(0, git(repo, "rev-parse", f"{include_ref}^{{commit}}").strip()) refs = tags.copy() - if not tags or history[0] != tags[0][1]: - refs.insert(0, ("Unreleased", history[0])) + if len(unreleased_refs) > 1 or not tags or history[0] != tags[0][1]: + refs.insert(0, ("Unreleased", unreleased_refs[0])) return [ ChangeSet( @@ -64,7 +78,11 @@ def generate_changelog(repo: Path) -> list[ChangeSet]: full_sha=sha, date=git(repo, "show", "-s", "--format=%cI", sha).strip(), # The oldest range has no lower bound, so it includes the root commit. - commits=commits_between(repo, sha, [older_sha for _, older_sha in refs[index + 1 :]]), + commits=commits_between( + repo, + unreleased_refs if name == "Unreleased" else [sha], + [older_sha for _, older_sha in refs[index + 1 :]], + ), ) for index, (name, sha) in enumerate(refs) ] @@ -73,9 +91,13 @@ def generate_changelog(repo: Path) -> list[ChangeSet]: def main() -> None: parser = argparse.ArgumentParser(description="Generate commit history from reachable Git version tags.") parser.add_argument("--repo", type=Path, default=Path()) + parser.add_argument( + "--ref", default="HEAD", help="Reference whose reachable version tags define the release groups." + ) + parser.add_argument("--include-ref", help="Include additional unreleased commits from this reference.") parser.add_argument("--output", type=Path, default=Path("ui/exported/CHANGELOG.json")) args = parser.parse_args() - changes = generate_changelog(args.repo) + changes = generate_changelog(args.repo, ref=args.ref, include_ref=args.include_ref) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(changes, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 07ff4da..682f993 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -67,9 +67,6 @@ jobs: - name: Install Python dependencies run: uv sync --frozen --all-packages --dev - - name: Check Python - run: .githooks/pre-commit --all - - name: Set up Node.js uses: actions/setup-node@v7 with: @@ -90,21 +87,11 @@ jobs: working-directory: ui run: bun install --frozen-lockfile - - name: Check frontend formatting - working-directory: ui - run: bun run format:check - - - name: Lint frontend - working-directory: ui - run: bun run lint:ci - - - name: Check frontend types - working-directory: ui - run: bun run lint:tsc - - - name: Test frontend - working-directory: ui - run: bun test + - name: Pre-commit checks + env: + # The full suite can exceed five minutes on hosted runners. + PYTEST_ADDOPTS: "--session-timeout=900" + run: .githooks/pre-commit --all - name: Build frontend working-directory: ui diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..def2c88 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,53 @@ +name: Publish commit history + +on: + workflow_dispatch: + push: + branches: [master, dev] + tags: ["v*"] + +permissions: + contents: read + +concurrency: + group: changelog-pages + cancel-in-progress: false + +jobs: + publish: + if: github.server_url == 'https://github.com' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.14" + + - name: Generate commit history + run: >- + python .github/scripts/generate_changelog.py + --ref origin/master + --include-ref origin/dev + --output "${{ runner.temp }}/changelog/CHANGELOG.json" + + - name: Upload commit history + uses: actions/upload-pages-artifact@v5 + with: + path: ${{ runner.temp }}/changelog + + - name: Publish commit history + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e4f38c4..bbdff52 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -78,9 +78,6 @@ jobs: - name: Install Python dependencies run: uv sync --frozen --all-packages --dev - - name: Check Python - run: .githooks/pre-commit --all - - name: Set up Node.js uses: actions/setup-node@v7 with: @@ -101,21 +98,11 @@ jobs: working-directory: ui run: bun install --frozen-lockfile - - name: Check frontend formatting - working-directory: ui - run: bun run format:check - - - name: Lint frontend - working-directory: ui - run: bun run lint:ci - - - name: Check frontend types - working-directory: ui - run: bun run lint:tsc - - - name: Test frontend - working-directory: ui - run: bun test + - name: Pre-commit checks + env: + # The full suite can exceed five minutes on hosted runners. + PYTEST_ADDOPTS: "--session-timeout=900" + run: .githooks/pre-commit --all - name: Build frontend working-directory: ui diff --git a/README.md b/README.md index 989e1a5..27b136c 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,25 @@ individual files or let Millwright handle new files automatically. > [!WARNING] > Millwright is still in alpha phase and may have bugs or incomplete features. +### If you like my work, you might also like my other projects: + +* [YTPTube](https://github.com/arabcoders/ytptube): Media download manager, automation interface, and media library preparation layer for yt-dlp. +* [WatchState](https://github.com/arabcoders/watchstate): Self-hosted service to sync your media servers without relying on 3rd-party external services. + +## Screenshots + +Dashboard: + +![Dashboard](ui/public/images/screenshots/dashbaord.jpg) + +Statistics: + +![Statistics](ui/public/images/screenshots/stats.jpg) + +Job details: + +![Job details and flow decisions](ui/public/images/screenshots/job-view.jpg) + ## How it works 1. Add your media folders as a **Library**. diff --git a/tests/test_changelog.py b/tests/test_changelog.py index fc2b27d..3907aa3 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -39,15 +39,19 @@ def commit(repo: Path, message: str, date: str = "2026-01-01T12:00:00+00:00") -> return git(repo, "rev-parse", "HEAD") -def generate(repo: Path) -> list[dict]: +def generate(repo: Path, *args: str) -> list[dict]: output = repo / "exported/CHANGELOG.json" - subprocess.run([sys.executable, str(SCRIPT), "--repo", str(repo), "--output", str(output)], check=True) + subprocess.run([sys.executable, str(SCRIPT), "--repo", str(repo), "--output", str(output), *args], check=True) return json.loads(output.read_text()) def test_untagged_root(repo: Path) -> None: root = commit(repo, "Initial media support\n\n- Keep this list\n- Keep indentation\n\n```\n example\n```") - head = commit(repo, "Add libraries", "2026-01-02T12:00:00+00:00") + head = commit( + repo, + "Add libraries\n\nCo-authored-by: Another Author \nCO-AUTHORED-BY: Other ", + "2026-01-02T12:00:00+00:00", + ) groups = generate(repo) @@ -55,6 +59,7 @@ def test_untagged_root(repo: Path) -> None: assert groups[0]["full_sha"] == head assert datetime.fromisoformat(groups[0]["date"]) == datetime(2026, 1, 2, 12, tzinfo=UTC) assert [item["full_sha"] for item in groups[0]["commits"]] == [head, root] + assert groups[0]["commits"][0]["message"] == "Add libraries" initial = groups[0]["commits"][-1] assert initial["message"] == git(repo, "show", "-s", "--format=%B", root) assert initial["author"] == "History Author" @@ -70,6 +75,8 @@ def test_tagged_ranges(repo: Path) -> None: groups = generate(repo) assert [group["tag"] for group in groups] == ["v0.1.0-alpha.2", "v0.1.0-alpha.1"] assert [[item["full_sha"] for item in group["commits"]] for group in groups] == [[second], [root]] + git(repo, "branch", "dev") + assert generate(repo, "--include-ref", "dev") == groups head = commit(repo, "Add playback") groups = generate(repo) @@ -91,3 +98,41 @@ def test_reachable_versions(repo: Path) -> None: assert [group["tag"] for group in groups] == ["Unreleased", "v0.1.0"] assert [[item["full_sha"] for item in group["commits"]] for group in groups] == [[head], [root]] + + +def test_development_after_release(repo: Path) -> None: + root = commit(repo, "Start conversion") + git(repo, "tag", "v0.1.0") + git(repo, "checkout", "-b", "dev") + development = commit(repo, "Development branch change") + + groups = generate(repo, "--ref", "main", "--include-ref", "dev") + + assert [group["tag"] for group in groups] == ["Unreleased", "v0.1.0"] + assert [[item["full_sha"] for item in group["commits"]] for group in groups] == [[development], [root]] + + +@pytest.mark.parametrize("tagged", [False, True]) +def test_combined_branches(repo: Path, tagged: bool) -> None: + root = commit(repo, "Start conversion") + if tagged: + git(repo, "tag", "v0.1.0") + shared = commit(repo, "Shared development") + git(repo, "branch", "dev") + primary = commit(repo, "Primary branch change") + git(repo, "update-ref", "refs/remotes/origin/master", primary) + git(repo, "checkout", "dev") + development = commit(repo, "Development branch change") + git(repo, "tag", "v9.0.0") + git(repo, "update-ref", "refs/remotes/origin/dev", development) + + groups = generate(repo, "--ref", "origin/master", "--include-ref", "origin/dev") + + assert [group["tag"] for group in groups] == (["Unreleased", "v0.1.0"] if tagged else ["Unreleased"]) + assert groups[0]["full_sha"] == development + commits = [item["full_sha"] for item in groups[0]["commits"]] + expected = {development, primary, shared} if tagged else {development, primary, shared, root} + assert set(commits) == expected + assert len(commits) == len(expected) + if tagged: + assert [item["full_sha"] for item in groups[1]["commits"]] == [root] diff --git a/ui/app/components/JobCard.vue b/ui/app/components/JobCard.vue index b3054b5..3ab3364 100644 --- a/ui/app/components/JobCard.vue +++ b/ui/app/components/JobCard.vue @@ -212,6 +212,17 @@
+ + Play + (); const statusOptions = props.dashboard ? dashboardJobStatusOptions : jobStatusOptions; @@ -268,8 +270,27 @@ const { loadDetail: loadFileDetail } = useFiles(); const overlay = useOverlay(); const fileLoadingId = ref(null); let fileModal: ReturnType> | null = null; +let playerModal: ReturnType> | null = null; +const playerModalFileId = ref(null); let disposed = false; +const openPlayer = (file: JobFile): void => { + if (disposed) return; + playerModal?.close(); + playerModalFileId.value = file.id; + playerModal = overlay.create(MediaPlayerModal, { + props: { open: true, fileId: file.id, title: file.path.split(/[\\/]/).at(-1) || file.path }, + destroyOnClose: true, + }); + const modal = playerModal; + void modal.open().result.then(() => { + if (playerModal === modal) { + playerModal = null; + playerModalFileId.value = null; + } + }); +}; + const openFile = async (id: number): Promise => { if (disposed || fileLoadingId.value !== null) return; fileLoadingId.value = id; @@ -303,9 +324,23 @@ const openFile = async (id: number): Promise => { } }; +const { on } = useEvents(); +const stopEvents = on((event) => { + if ( + event.type === 'file.status' && + event.payload.media_file_id === playerModalFileId.value && + (event.payload.action === 'deleted' || event.payload.action === 'pruned') + ) { + playerModal?.close(); + playerModalFileId.value = null; + } +}); + onBeforeUnmount(() => { disposed = true; + stopEvents(); fileModal?.close(); + playerModal?.close(); }); const selectedIds = ref>(new Set()); diff --git a/ui/app/pages/changelog.vue b/ui/app/pages/changelog.vue index a463dc6..5db88d9 100644 --- a/ui/app/pages/changelog.vue +++ b/ui/app/pages/changelog.vue @@ -2,16 +2,6 @@
diff --git a/ui/app/pages/index.vue b/ui/app/pages/index.vue index ce50c6f..6a15336 100644 --- a/ui/app/pages/index.vue +++ b/ui/app/pages/index.vue @@ -300,6 +300,7 @@ :compact="compact" :busy="processingLoading || actionLoading.has(job.id)" @open="openDetail(job.id)" + @play="job.file && openFilePlayer(job.file)" @open-file="openRecentFile({ id: $event })" @cancel="cancelProcessingJob(job)" /> @@ -640,7 +641,7 @@ const openDetailDryRun = (): void => { } void openFileDryRun(fileDetail.value); }; -const openFilePlayer = (file: MediaFileRow): void => { +const openFilePlayer = (file: Pick): void => { if (disposed || fileActionBusy.value || healthChecking.value || transcoding.value) return; playerModal?.close(); playerModalFileId.value = file.id; diff --git a/ui/app/utils/changelog.ts b/ui/app/utils/changelog.ts index 2870824..beef1c2 100644 --- a/ui/app/utils/changelog.ts +++ b/ui/app/utils/changelog.ts @@ -13,6 +13,8 @@ export type ChangelogGroup = { commits: ChangelogCommit[]; }; +const RELEASE_LIMIT = 10; + export const loadChangelog = async ( version: string, bundledUrl: string, @@ -44,10 +46,11 @@ export const loadChangelog = async ( }; export const filterChangelog = (groups: ChangelogGroup[], query: string): ChangelogGroup[] => { + const visible = groups.slice(0, RELEASE_LIMIT); const search = query.trim().toLowerCase(); - if (!search) return groups; + if (!search) return visible; - return groups.flatMap((group) => { + return visible.flatMap((group) => { if (group.tag.toLowerCase().includes(search)) return [group]; const commits = group.commits.filter((commit) => [commit.message, commit.author, commit.full_sha].some((value) => @@ -58,7 +61,53 @@ export const filterChangelog = (groups: ChangelogGroup[], query: string): Change }); }; -export const splitCommitMessage = (message: string): { title: string; description: string } => { - const [title = '', ...lines] = message.split(/\r?\n/); - return { title, description: lines.join('\n').replace(/^\n+|\n+$/g, '') }; +export const isInstalledGroup = (group: ChangelogGroup, installedSha: string): boolean => { + if (!installedSha) return false; + return ( + group.full_sha.startsWith(installedSha) || + group.commits.some((commit) => commit.full_sha.startsWith(installedSha)) + ); +}; + +const compareVersions = (left: string, right: string): number => { + const leftParts = left + .replace(/^v/i, '') + .split('.') + .map((part) => Number.parseInt(part, 10) || 0); + const rightParts = right + .replace(/^v/i, '') + .split('.') + .map((part) => Number.parseInt(part, 10) || 0); + + for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) { + const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (difference !== 0) return difference; + } + return 0; +}; + +export const initialChangelogReleases = ( + groups: ChangelogGroup[], + installedVersion: string, + installedSha: string, +): string[] => { + const visible = groups.slice(0, RELEASE_LIMIT); + const installedIndex = visible.findIndex((group) => isInstalledGroup(group, installedSha)); + return ( + installedIndex >= 0 + ? visible.slice(0, installedIndex + 1) + : visible.filter((group) => compareVersions(group.tag, installedVersion) >= 0) + ).map((group) => group.tag); +}; + +export const formatCommitMessage = (message: string): { title: string; description: string[] } => { + const lines = message + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const title = lines.shift() ?? ''; + return { + title: `${(title.charAt(0).toUpperCase() + title.slice(1)).replace(/\.$/, '')}.`, + description: lines, + }; }; diff --git a/ui/app/utils/topLevelNavigation.ts b/ui/app/utils/topLevelNavigation.ts index de17d2b..6fc5b63 100644 --- a/ui/app/utils/topLevelNavigation.ts +++ b/ui/app/utils/topLevelNavigation.ts @@ -211,7 +211,7 @@ const NavItems: Array = [ section: 'documentation', group: 'documentation', label: 'Changelog', - description: 'Commit history grouped by release, with the installed build marked.', + description: 'Latest project changes', icon: 'i-lucide-history', to: '/changelog', }, diff --git a/ui/public/images/screenshots/dashbaord.jpg b/ui/public/images/screenshots/dashbaord.jpg new file mode 100644 index 0000000..271b80e Binary files /dev/null and b/ui/public/images/screenshots/dashbaord.jpg differ diff --git a/ui/public/images/screenshots/job-view.jpg b/ui/public/images/screenshots/job-view.jpg new file mode 100644 index 0000000..86c48ae Binary files /dev/null and b/ui/public/images/screenshots/job-view.jpg differ diff --git a/ui/public/images/screenshots/stats.jpg b/ui/public/images/screenshots/stats.jpg new file mode 100644 index 0000000..a9d9788 Binary files /dev/null and b/ui/public/images/screenshots/stats.jpg differ diff --git a/ui/tests/changelog.test.ts b/ui/tests/changelog.test.ts index 44c6e51..bf6b212 100644 --- a/ui/tests/changelog.test.ts +++ b/ui/tests/changelog.test.ts @@ -1,8 +1,9 @@ import { describe, expect, spyOn, test } from 'bun:test'; import { filterChangelog, + formatCommitMessage, + initialChangelogReleases, loadChangelog, - splitCommitMessage, type ChangelogGroup, } from '../app/utils/changelog'; @@ -116,15 +117,52 @@ describe('commit history filtering', () => { expect(filterChangelog(groups, 'alpha.1')).toEqual([groups[1]!]); expect(filterChangelog(groups, ' ')).toEqual(groups); }); + + test('limits visible releases', () => { + const history = Array.from({ length: 11 }, (_, index) => ({ + ...groups[0]!, + tag: `v0.1.${10 - index}`, + })); + const visible = filterChangelog(history, ''); + expect(visible).toHaveLength(10); + expect(visible.at(-1)?.tag).toBe('v0.1.1'); + expect(filterChangelog(history, 'v0.1.0')).toEqual([]); + }); +}); + +test('opens installed releases', () => { + expect(initialChangelogReleases(groups, 'dev', '11223344')).toEqual(['Unreleased']); + expect(initialChangelogReleases(groups, 'v0.1.0-alpha.1', 'abcdef')).toEqual([ + 'Unreleased', + 'v0.1.0-alpha.1', + ]); }); -test('preserves Markdown body', () => { +test('uses version fallback', () => { + const history = [ + groups[0]!, + ...['v0.1.10', 'v0.1.9', 'v0.1.8'].map((tag) => ({ ...groups[1]!, tag })), + ]; + expect(initialChangelogReleases(history, 'v0.1.9', '')).toEqual(['v0.1.10', 'v0.1.9']); + expect(initialChangelogReleases(history, 'dev', '')).toEqual([ + 'Unreleased', + 'v0.1.10', + 'v0.1.9', + 'v0.1.8', + ]); +}); + +test('formats commit messages', () => { expect( - splitCommitMessage( - 'fix: preserve text.\r\n\r\n indented code\r\n\r\n- First\r\n - Nested\r\n\r\n```\r\n code\r\n```\r\n', + formatCommitMessage( + '\r\nfix: preserve text.\r\n\r\n indented code\r\n\r\n- First\r\n - Nested\r\n\r\n```\r\n code\r\n```\r\n', ), ).toEqual({ - title: 'fix: preserve text.', - description: ' indented code\n\n- First\n - Nested\n\n```\n code\n```', + title: 'Fix: preserve text.', + description: ['indented code', '- First', '- Nested', '```', 'code', '```'], + }); + expect(formatCommitMessage('feat: add libraries')).toEqual({ + title: 'Feat: add libraries.', + description: [], }); });