diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..2277541
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+version: 2
+
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: weekly
+ groups:
+ github-actions:
+ patterns:
+ - "*"
diff --git a/.github/workflows/lint-actions.yml b/.github/workflows/lint-actions.yml
new file mode 100644
index 0000000..a6066a2
--- /dev/null
+++ b/.github/workflows/lint-actions.yml
@@ -0,0 +1,46 @@
+name: Lint Actions
+
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - ".github/workflows/**"
+ pull_request:
+ paths:
+ - ".github/workflows/**"
+
+concurrency:
+ group: lint-actions-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
+
+jobs:
+ actionlint:
+ name: Lint GitHub Actions workflows
+ runs-on: ubuntu-latest
+ env:
+ ACTIONLINT_VERSION: "1.7.8"
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Install actionlint
+ shell: bash
+ run: |
+ set -euo pipefail
+ archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
+ base_url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}"
+ curl -fsSLO "${base_url}/${archive}"
+ curl -fsSLO "${base_url}/actionlint_${ACTIONLINT_VERSION}_checksums.txt"
+ grep " ${archive}$" "actionlint_${ACTIONLINT_VERSION}_checksums.txt" | sha256sum --check
+ tar -xzf "${archive}" actionlint
+ sudo install actionlint /usr/local/bin/actionlint
+
+ - name: Lint workflows
+ run: actionlint
diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml
new file mode 100644
index 0000000..8652b91
--- /dev/null
+++ b/.github/workflows/lint-docs.yml
@@ -0,0 +1,43 @@
+name: Lint Docs
+
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - "**/*.md"
+ - ".markdownlint.json"
+ - ".github/workflows/lint-docs.yml"
+ pull_request:
+ paths:
+ - "**/*.md"
+ - ".markdownlint.json"
+ - ".github/workflows/lint-docs.yml"
+
+concurrency:
+ group: lint-docs-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
+
+jobs:
+ markdownlint:
+ name: Lint Markdown
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Lint Markdown
+ uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
+ with:
+ # Changelogs are generated by git-cliff; LICENSE.md preserves canonical licence text.
+ globs: |
+ **/*.md
+ #CHANGELOG.md
+ #CHANGELOG_RELEASE.md
+ #LICENSE.md
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index bc5e932..46ea43b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -12,18 +12,29 @@ permissions:
jobs:
release:
runs-on: ubuntu-latest
-
+ env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- - name: Check out repo
- uses: actions/checkout@v4
+ - name: Check out repo
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- fetch-tags: true
+ fetch-tags: true
+
+ - name: Install test and packaging dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y make shellcheck unzip zip
+
+ - name: Run tests and ShellCheck
+ run: |
+ make test
+ shellcheck chdtool.sh scripts/*.sh tests/*.sh tests/bin/*
# Generate release notes for this tag (used as Release body)
- name: Generate release notes with git-cliff
id: cliff_notes
- uses: orhun/git-cliff-action@v4
+ uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # v4
with:
config: cliff.toml
# notes only for the pushed tag, without the big header
@@ -32,23 +43,21 @@ jobs:
OUTPUT: CHANGELOG_RELEASE.md
GITHUB_REPO: ${{ github.repository }}
- - name: Prepare artifacts
- run: |
- set -euo pipefail
- chmod +x chdtool.sh || true
- cp chdtool.sh chdtool
- mkdir -p dist
- tar -czf "dist/chdtool-${GITHUB_REF_NAME}.tar.gz" chdtool README.md LICENSE CHANGELOG.md || true
- zip -j "dist/chdtool-${GITHUB_REF_NAME}.zip" chdtool README.md LICENSE CHANGELOG.md || true
+ - name: Prepare artifacts
+ run: |
+ set -euo pipefail
+ release_version="${GITHUB_REF_NAME#v}"
+ DIST_DIR="${RUNNER_TEMP}/chdtool-dist" \
+ SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" \
+ bash scripts/package-release.sh "$release_version"
- name: Create GitHub Release and upload assets
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: ${{ github.ref_name }}
body_path: ${{ steps.cliff_notes.outputs.changelog }} # file generated above
- files: |
- dist/*
- chdtool.sh
+ files: |
+ ${{ runner.temp }}/chdtool-dist/*
draft: false
prerelease: ${{ contains(github.ref_name, '-') }}
env:
@@ -57,7 +66,7 @@ jobs:
# Regenerate the full CHANGELOG.md for main and open a PR
- name: Regenerate full CHANGELOG.md
id: cliff_full
- uses: orhun/git-cliff-action@v4
+ uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # v4
with:
config: cliff.toml
args: -vv
@@ -66,7 +75,7 @@ jobs:
GITHUB_REPO: ${{ github.repository }}
- name: Open PR to update CHANGELOG.md on main
- uses: peter-evans/create-pull-request@v6
+ uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
commit-message: "chore(changelog): update for ${{ github.ref_name }}"
branch: chore/update-changelog-${{ github.ref_name }}
diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml
index 39eaaef..55b271f 100644
--- a/.github/workflows/shellcheck.yml
+++ b/.github/workflows/shellcheck.yml
@@ -1,11 +1,37 @@
-name: ShellCheck
-on: [push, pull_request]
-jobs:
- shellcheck:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Install ShellCheck
- run: sudo apt-get update && sudo apt-get install -y shellcheck
- - name: Run ShellCheck
- run: shellcheck chdtool.sh
\ No newline at end of file
+name: ShellCheck
+
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - "chdtool.sh"
+ - "tests/**/*.sh"
+ - "tests/bin/*"
+ - "scripts/**/*.sh"
+ - ".github/workflows/shellcheck.yml"
+ pull_request:
+ paths:
+ - "chdtool.sh"
+ - "tests/**/*.sh"
+ - "tests/bin/*"
+ - "scripts/**/*.sh"
+ - ".github/workflows/shellcheck.yml"
+
+concurrency:
+ group: shellcheck-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ shellcheck:
+ runs-on: ubuntu-latest
+ env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Install ShellCheck
+ run: sudo apt-get update && sudo apt-get install -y shellcheck
+ - name: Run ShellCheck
+ run: shellcheck chdtool.sh scripts/*.sh tests/*.sh tests/bin/*
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 3e09095..0601dcc 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -1,31 +1,48 @@
-name: tests
+name: Tests
+
on:
+ workflow_dispatch:
push:
+ paths:
+ - "chdtool.sh"
+ - "Makefile"
+ - "tests/**"
+ - "scripts/**"
+ - ".github/workflows/tests.yml"
pull_request:
+ paths:
+ - "chdtool.sh"
+ - "Makefile"
+ - "tests/**"
+ - "scripts/**"
+ - ".github/workflows/tests.yml"
+
+concurrency:
+ group: tests-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
jobs:
tests:
runs-on: ubuntu-latest
+ env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install test deps
run: |
sudo apt-get update
sudo apt-get install -y unzip zip make
- - name: Debug workspace
- run: |
- pwd
- ls -la
- sed -n '1,120p' Makefile
-
- name: Run tests
run: make test
- name: Upload logs from tests (always)
if: ${{ always() }}
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: logs-from-tests
path: logs/
diff --git a/.gitignore b/.gitignore
index 3fba4fc..1773235 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,11 +12,13 @@ Thumbs.db
logs/
*.log
-# Archives or temp outputs (just in case)
-*.zip
-*.7z
-*.rar
-*.tmp
+# Archives or temp outputs (just in case)
+*.zip
+*.7z
+*.rar
+*.tmp
+/chdtool
+/dist/
# Python virtual envs (if any tooling is added later)
.venv/
diff --git a/.markdownlint.json b/.markdownlint.json
new file mode 100644
index 0000000..b077f0e
--- /dev/null
+++ b/.markdownlint.json
@@ -0,0 +1,4 @@
+{
+ "default": true,
+ "MD013": false
+}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 084519a..9e55213 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,25 +1,28 @@
# Contributing
-Thanks for your interest in contributing to **chdscript**! ๐
+Thanks for your interest in contributing to **chdtool**! ๐
## How to Contribute
-1. **Fork the repo** and create your feature branch:
- ```bash
- git checkout -b feature/my-new-feature
+1. **Fork the repo** and create your feature branch:
+
+ ```bash
+ git checkout -b feature/my-new-feature
+ ```
+
+2. **Commit your changes** with clear messages:
+
+ ```bash
+ git commit -m "Add support for XYZ"
+ ```
+
+3. **Push to the branch**:
+
+ ```bash
+ git push origin feature/my-new-feature
```
-2. **Commit your changes** with clear messages:
- ```bash
- git commit -m "Add support for XYZ"
- ```
-
-3. **Push to the branch**:
- ```bash
- git push origin feature/my-new-feature
- ```
-
-4. **Open a Pull Request** against `main`.
+4. **Open a Pull Request** against `develop`.
## Coding Guidelines
@@ -37,11 +40,16 @@ Thanks for your interest in contributing to **chdscript**! ๐
## Versioning
-We use [Semantic Versioning](https://semver.org/) (semver).
-Bump version numbers when you:
-- `MAJOR`: Break backward compatibility
+We use [Semantic Versioning](https://semver.org/) (semver).
+
+Bump version numbers when you:
+
+- `MAJOR`: Break backward compatibility
- `MINOR`: Add new functionality in a backward-compatible manner
-- `PATCH`: Fix bugs or make small improvements
+- `PATCH`: Fix bugs or make small improvements
+
+Maintainers should follow the complete [release procedure](RELEASING.md) when
+preparing, tagging, publishing, and verifying a release.
---
diff --git a/Makefile b/Makefile
index cf57f67..0e7f378 100644
--- a/Makefile
+++ b/Makefile
@@ -3,15 +3,22 @@ SHELL := /usr/bin/env bash
# Path to your script; override with: make test SCRIPT=./path/to/script.sh
SCRIPT ?= ./chdtool.sh
-.PHONY: test test-m3u test-m3u-single test-logging test-setup clean changelog changelog-tag
+.PHONY: test test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-exit-status test-logging test-security-validation test-resource-handling test-regression-gaps test-cli test-package test-setup clean changelog changelog-tag package
-test: test-setup test-m3u test-m3u-single test-logging
+test: test-setup test-m3u test-m3u-single test-partial-resume test-transactional-source-deletion test-temporary-chd-cleanup test-exit-status test-logging test-security-validation test-resource-handling test-regression-gaps test-cli test-package
test-setup:
chmod +x tests/bin/chdman
chmod +x tests/bin/unrar
-chmod +x tests/bin/7z
- chmod +x tests/test_m3u.sh tests/test_m3u_single.sh
+ chmod +x tests/test_m3u.sh tests/test_m3u_single.sh tests/test_transactional_source_deletion.sh
+ chmod +x tests/test_temporary_chd_cleanup.sh
+ chmod +x tests/test_partial_resume.sh
+ chmod +x tests/test_exit_status.sh
+ chmod +x tests/test_security_validation.sh
+ chmod +x tests/test_resource_handling.sh
+ chmod +x tests/test_regression_gaps.sh
+ chmod +x tests/test_cli.sh tests/test_package.sh scripts/package-release.sh
@if [ -f tests/test_logging.sh ]; then chmod +x tests/test_logging.sh; fi
test-m3u:
@@ -20,6 +27,18 @@ test-m3u:
test-m3u-single:
SCRIPT=$(SCRIPT) bash tests/test_m3u_single.sh
+test-partial-resume:
+ SCRIPT=$(SCRIPT) bash tests/test_partial_resume.sh
+
+test-transactional-source-deletion:
+ SCRIPT=$(SCRIPT) bash tests/test_transactional_source_deletion.sh
+
+test-temporary-chd-cleanup:
+ SCRIPT=$(SCRIPT) bash tests/test_temporary_chd_cleanup.sh
+
+test-exit-status:
+ SCRIPT=$(SCRIPT) bash tests/test_exit_status.sh
+
test-logging:
@if [ -f tests/test_logging.sh ]; then \
SCRIPT=$(SCRIPT) bash tests/test_logging.sh ; \
@@ -27,11 +46,29 @@ test-logging:
echo "Skipping logging test (tests/test_logging.sh not present)"; \
fi
+test-security-validation:
+ SCRIPT=$(SCRIPT) bash tests/test_security_validation.sh
+
+test-resource-handling:
+ SCRIPT=$(SCRIPT) bash tests/test_resource_handling.sh
+
+test-regression-gaps:
+ SCRIPT=$(SCRIPT) bash tests/test_regression_gaps.sh
+
+test-cli:
+ SCRIPT=$(SCRIPT) bash tests/test_cli.sh
+
+test-package:
+ bash tests/test_package.sh
+
clean:
@echo "Nothing to clean; tests use mktemp dirs."
changelog:
- git cliff --config .cliff.toml -o CHANGELOG.md
+ git cliff --config cliff.toml -o CHANGELOG.md
changelog-tag:
- git cliff --config .cliff.toml --tag $${TAG} --strip header > CHANGELOG_RELEASE.md
+ git cliff --config cliff.toml --tag $${TAG} --strip header > CHANGELOG_RELEASE.md
+
+package:
+ bash scripts/package-release.sh "$${VERSION:-$$(sed -n 's/^CHDTOOL_VERSION="\([^"]*\)"/\1/p' chdtool.sh)}"
diff --git a/README.md b/README.md
index 34e87ef..c51d47d 100644
--- a/README.md
+++ b/README.md
@@ -1,102 +1,281 @@
-# ๐ฎ chdtool
+# ๐ฎ chdtool
+
+[](https://github.com/lloydsmart/chdtool/blob/main/LICENSE.md)
+[](https://github.com/lloydsmart/chdtool/releases)
+[](https://github.com/lloydsmart/chdtool/actions/workflows/shellcheck.yml)
-[](https://github.com/lloydsmart/chdtool/blob/master/LICENSE) [](https://github.com/lloydsmart/chdtool/releases) [](https://github.com/lloydsmart/chdtool/actions/workflows/shellcheck.yml)
+A robust Bash script for converting CD/DVD disc images and archives into **CHD (Compressed Hunks of Data)** format using `chdman`.
-A Bash script that converts CD/DVD disc images (ZIP/RAR/7Z/ISO/CUE/GDI/CCD) into CHD using chdman.
+Designed for batch processing, validation, logging, and multi-disc handling โ ideal for emulation libraries.
-Convert, verify, and manage disc-based game images into CHD format with ease.
-Supports recursive scanning, CD/DVD detection, archive extraction, and more.
+---
+
+Convert everything in a directory:
+
+```bash
+./chdtool.sh -r /path/to/roms
+```
---
-## โจ Features
+## โจ Features
-- ๐ฆ Extracts archives (`.zip`, `.rar`, `.7z`)
-- ๐ฟ Converts **CD images** via `chdman createcd`
-- ๐ Converts **DVD images** via `chdman createdvd` (if available)
-- ๐ Verifies existing CHDs before conversion
-- ๐๏ธ Cleans up temp files automatically
-- ๐ Logs space savings per file and in total
-- ๐ Recursive directory scanning
-- โ๏ธ Option to keep originals with `--keep-originals`
+- ๐ฆ Supports common input formats:
+ - Archives: `zip`, `rar`, `7z`
+ - Disc images: `iso`, `cue`, `gdi`, `ccd`
+- ๐ Automatic extraction of archives before conversion
+- ๐ฟ Intelligent CD vs DVD detection (`createcd` vs `createdvd`)
+- โ
Verification of CHDs using `chdman verify`
+ - Automatic retry on failure
+ - Deletes invalid CHDs
+- ๐ Space savings reporting
+- ๐งพ Automatic M3U generation for multi-disc sets
+- ๐งน Safe temp directory handling with cleanup traps
+- ๐งช Dry-run mode (no source or conversion changes)
+- ๐ชต Structured logging system:
+ - Console / file / syslog / journald
+ - Configurable verbosity
+- โก Progress bar (TTY-aware, non-spammy)
---
## ๐ Usage
+
```bash
-./chdtool.sh [--keep-originals|-k] [--recursive|-r]
+./chdtool.sh [options]
```
-Examples:
+### Options
+
+| Option | Description |
+| ------ | ----------- |
+| `-k`, `--keep-originals` | Do not delete source files after conversion |
+| `-r`, `--recursive` | Process subdirectories |
+| `-n`, `--dry-run` | Show what would happen without making changes |
+| `-a`, `--allow-unverified-cue-audio` | Permit lossy or unverified CUE audio tracks (not preservation-safe) |
+| `-F`, `--file-tee` | Force logging to file |
+| `-N`, `--no-file-tee` | Disable logging to file |
+| `-h`, `--help` | Show usage information |
+| `-V`, `--version` | Show the packaged version |
+
+Use `--` to end option parsing when an input directory starts with `-`:
+
+```bash
+./chdtool.sh -- -roms
+```
+
+---
+
+## ๐ Example
+
```bash
-./chdtool.sh /path/to/dir
-./chdtool.sh -k -r /roms
+./chdtool.sh -r /mnt/roms
```
-### Options
+Dry run:
-- `--keep-originals` โ Keep input archives/discs after conversion
-- `--help` โ Show usage
+```bash
+./chdtool.sh -n /mnt/roms
+```
---
-## ๐ Example Run
-
-```text
-[2025-07-05 14:00:12] ๐ Script started, input dir: /mnt/retronas/roms/sega/saturn/
-[2025-07-05 14:00:12] โน๏ธ Using chdman - MAME Compressed Hunks of Data (CHD) manager 0.251 (unknown)
-[2025-07-05 14:00:12] ๐ Found 6 inputs
-[2025-07-05 14:00:12] โถ๏ธ Processing file: /mnt/retronas/roms/sega/saturn/Virtua Fighter (Europe).zip
-[2025-07-05 14:00:12] ๐ฆ Extracting Virtua Fighter (Europe).zip to /tmp/chdconv_Virtua Fighter (Europe)_a1B2
-[2025-07-05 14:00:14] ๐ฟ Converting: Virtua Fighter (Europe).cue โ Virtua Fighter (Europe).chd.tmp
-Compression complete ... final ratio = 58.7%
-[2025-07-05 14:01:02] ๐ Verifying: Virtua Fighter (Europe).chd.tmp
-[2025-07-05 14:01:20] โ
Verified CHD: Virtua Fighter (Europe).chd.tmp
-[2025-07-05 14:01:20] ๐ Replaced old CHD with new verified CHD: Virtua Fighter (Europe).chd
-[2025-07-05 14:01:20] ๐๏ธ Removing original input file: Virtua Fighter (Europe).zip
-[2025-07-05 14:01:20] ๐ Space saving for Virtua Fighter (Europe).zip: 640 MB โ 375 MB, saved 265 MB (41%)
-[2025-07-05 14:01:20] ๐ค Raw base name: Virtua Fighter (Europe)
-[2025-07-05 14:01:20] ๐ค Normalized base name for M3U: virtua fighter
-[2025-07-05 14:01:20] ๐ Found 1 CHD candidate, remaining inputs: yes
-[2025-07-05 14:01:20] ๐งน Cleaned up temp dir on exit: /tmp/chdconv_Virtua Fighter (Europe)_a1B2
-[2025-07-05 14:01:20] ๐ All inputs processed successfully!
-[2025-07-05 14:01:20] ๐ Summary:
-[2025-07-05 14:01:20] ๐ฆ Total original size: 5 GB
-[2025-07-05 14:01:20] ๐ฟ Total CHD size: 4 GB
-[2025-07-05 14:01:20] ๐ Total space saved: 1 GB (23%)
-[2025-07-05 14:01:20] ๐ฆ Archives processed: 6
-[2025-07-05 14:01:20] ๐ฟ CHDs created: 6
-[2025-07-05 14:01:20] โ Failures: 0
-[2025-07-05 14:01:20] โฑ๏ธ Elapsed time: 55m 56s
-[2025-07-05 14:01:20] โ
Done!
+## ๐ฆ Supported Input Formats
+### Disc images
+
+- `.iso`
+- `.cue` (with referenced BIN/WAV/MP3 validation)
+- `.gdi`
+- `.ccd`
+
+### Archives
+
+- `.zip`
+- `.rar`
+- `.7z`
+
+Archives are extracted to a temporary directory and processed automatically.
+
+---
+
+## ๐ฟ Output
+
+- CHDs are created alongside the input files
+- Temporary files use `.tmp` suffix until verified
+- Originals are removed unless `--keep-originals` is set
+
+---
+
+## ๐งพ Multi-disc Support
+
+- Automatically detects disc numbering patterns:
+ - `Disc 1`, `CD2`, `Part 3`, `Side A`, `1 of 2`, etc.
+- Generates `.m3u` playlists when **2+ discs** are detected
+- Filenames are sanitized for cross-platform compatibility
+
+Example:
+
+```text
+Final Fantasy VII (Disc 1).chd
+Final Fantasy VII (Disc 2).chd
+Final Fantasy VII.m3u
```
---
-## Notes
-- Temporary CHDs are created as `*.chd.tmp` then verified and renamed.
-- Logs are written under `logs/`.
-- DVD detection: prefers UDF via `file`, otherwise size heuristic (โฅ1 GB โ DVD).
+## ๐ Verification
+
+- All CHDs are verified with `chdman verify`
+- Failed verification:
+ - Retried once
+ - Deleted if still invalid
+- Existing CHDs are verified before skipping conversion
+
+---
+
+## ๐ชต Logging
+
+Configurable via environment variables:
+
+```bash
+LOG_DEST=auto|console|file|syslog|journald
+LOG_LEVEL_THRESHOLD=DEBUG|INFO|WARN|ERROR
+LOG_TEE_CONSOLE=auto|1|0
+LOG_TEE_FILE=1|0
+LOG_TAG=chdtool
+LOGFILE=/custom/path/chdtool.log
+```
+
+Default log file:
+
+```text
+logs/chd_conversion_.log
+```
+
+`--file-tee` and `--no-file-tee` control the optional file mirror for console,
+syslog, and journald backends. When `LOG_DEST=file`, the file is the primary
+destination and is therefore still written. A caller-supplied `LOGFILE` path is
+used verbatim.
+
+---
+
+## ๐ Output Summary
+
+At the end of a run:
+
+- Total original size
+- Total CHD size
+- Space saved
+- Archives processed
+- CHDs created
+- Failures
+- Elapsed time
+
+### Exit statuses
+
+- `0` โ clean success, including intentional skips and already-complete inputs
+- `1` โ usage, configuration, or startup failure
+- `2` โ one or more discovered inputs failed; independent inputs were still processed
+- `130` โ interrupted by SIGINT or SIGTERM
+
+---
+
+## โ๏ธ Requirements
+
+The following tools must be installed:
+
+- `chdman`
+- `unzip`
+- `unrar`
+- `7z`
+- `stat`
+- `awk`
+- `stdbuf`
+
+Optional (enhancements):
+
+- `file` (better ISO detection)
+- `perl` (improved filename parsing)
+- `uconv` (Unicode normalization)
+- `systemd-cat` / `logger` (logging backends)
+
+---
+
+## ๐งช Dry Run Mode
+
+Use `--dry-run` to preview actions:
+
+```bash
+./chdtool.sh -n /roms
+```
+
+- No source files, CHDs, playlists, or temporary conversion workspaces are
+ created, moved, or deleted
+- All intended operations are logged
+
+Logging remains active during a dry run and may create the configured log file.
+Use `--no-file-tee` with the console, syslog, or journald backend to prevent a
+file mirror.
+
+### Resource selection
+
+By default, CHD Tool lets `chdman` choose its format-appropriate hunk size. It
+selects compression threads conservatively from Linux `MemAvailable` (never
+swap), using roughly 2 GiB per CD thread or 4 GiB per DVD thread, and caps the
+result at the available CPU count with a minimum of one.
+
+- `CHDMAN_THREADS=N` requests a positive thread count; values above the CPU count
+ are capped
+- `CHDMAN_HUNK_SIZE=N` is an advanced opt-in that passes `-hs N` to `chdman`
+
+---
-## ๐ Roadmap
+## โ ๏ธ Notes
-- [ ] M3U playlist generation for multi-disc sets
-- [ ] Detect multi-part RAR/7z archives
-- [ ] Add parallel conversion mode
+- DVD support requires a version of `chdman` with `createdvd`
+- CUE files referencing missing files will fail validation
+- CUE/GDI/CCD paths must be relative and remain below the descriptor directory;
+ nested paths and unambiguous case-insensitive matches are supported
+- A successfully converted direct descriptor is treated as one source set: unless
+ `--keep-originals` is used, the descriptor and all validated companion tracks
+ are removed together. Any validation or conversion failure retains the full set
+- Archive members are rejected before conversion when paths escape the extraction
+ directory, links are present, or selected entries collide after name sanitisation
+- Temporary files are cleaned automatically, even on interruption
+- `--allow-unverified-cue-audio` relaxes preservation checks for lossy or
+ otherwise unverified CUE audio tracks; use it only when accepting that risk
+
+---
+
+## ๐ฆ Release assets
+
+Each release publishes `.tar.gz` and `.zip` archives containing `chdtool`,
+`README.md`, `LICENSE.md`, and `CHANGELOG.md`, plus a standalone `chdtool`
+script. Verify downloads with the published checksum file:
+
+```bash
+sha256sum --check SHA256SUMS
+```
+
+Maintainers should use the documented [release procedure](RELEASING.md) rather
+than creating tags or assets manually.
---
-## ๐ Requirements
+## ๐ ๏ธ Design Goals
-- `bash` โฅ 5.0
-- `chdman` (from [MAME](https://www.mamedev.org/))
-- `p7zip`, `unrar`, `unzip` for archives
-- `file` utility (for CD/DVD detection)
-- stat, file, find, grep, sort, tee
+- Safe by default (verify before replace)
+- Idempotent (re-runs donโt duplicate work)
+- Transparent logging
+- Minimal dependencies
+- Works well on large ROM collections
---
-## ๐ License
+## ๐ Future Ideas
-[MIT License](LICENSE) ยฉ 2025 Lloyd Smart
+- Parallel processing
+- Better metadata integration
+- Optional compression tuning
+- Integration with tools like Retromount / ROM managers
diff --git a/RELEASING.md b/RELEASING.md
new file mode 100644
index 0000000..addc973
--- /dev/null
+++ b/RELEASING.md
@@ -0,0 +1,147 @@
+# Release procedure
+
+This project uses a gitflow-style release process. Development is integrated on
+`develop`; releases are made from `main` and triggered by tags named
+`vMAJOR.MINOR.PATCH`.
+
+## Prerequisites
+
+- Push access to `lloydsmart/chdtool`
+- Permission to merge into `develop` and `main`
+- GitHub Actions permission to create pull requests
+- Bash, Make, ShellCheck, `tar`, `gzip`, `zip`, `unzip`, and `sha256sum`
+- `git-cliff` when generating changelogs locally
+
+Start with a clean, current checkout:
+
+```bash
+git switch develop
+git pull --ff-only origin develop
+git status --short
+```
+
+The final command should print nothing.
+
+## 1. Choose and set the version
+
+Choose the next version according to Semantic Versioning. Create a release
+branch from `develop`. Keep the `v` in the branch name when using git-flow:
+
+```bash
+git switch -c release/v0.2.5
+```
+
+Update `CHDTOOL_VERSION` near the top of `chdtool.sh`. Confirm that the CLI
+reports the intended value:
+
+```bash
+./chdtool.sh --version
+```
+
+The release workflow rejects a tag whose version does not match this value.
+
+## 2. Run the preflight checks
+
+Run the same functional and shell checks used by the release workflow:
+
+```bash
+make test
+shellcheck chdtool.sh scripts/*.sh tests/*.sh tests/bin/*
+```
+
+When `actionlint` and `markdownlint-cli2` are available, also run:
+
+```bash
+actionlint
+npx --yes markdownlint-cli2 "**/*.md" "#CHANGELOG.md" \
+ "#CHANGELOG_RELEASE.md" "#LICENSE.md"
+```
+
+Build and validate the release assets locally:
+
+```bash
+make package VERSION=0.2.5
+(cd dist && sha256sum --check SHA256SUMS)
+tar -tzf dist/chdtool-v0.2.5.tar.gz
+unzip -Z1 dist/chdtool-v0.2.5.zip
+```
+
+Both archives must contain exactly one top-level `chdtool-v0.2.5` directory
+with these files:
+
+- `chdtool`
+- `README.md`
+- `LICENSE.md`
+- `CHANGELOG.md`
+
+The `dist/` directory is rebuilt from scratch by the packaging command.
+
+## 3. Promote the release to main
+
+Commit the version change, push the release branch, and open a pull request
+directly into `main`:
+
+```bash
+git add chdtool.sh
+git commit -m "chore: prepare v0.2.5"
+git push -u origin release/v0.2.5
+```
+
+The pull request represents the complete promotion of the release candidate
+from `develop` to `main`. Merge only after all required checks pass. Do not tag
+the release branch or an unmerged commit.
+
+## 4. Tag and publish
+
+Update local `main` and verify the version one final time:
+
+```bash
+git switch main
+git pull --ff-only origin main
+./chdtool.sh --version
+git status --short
+```
+
+Create an annotated tag on that exact commit and push only the tag:
+
+```bash
+git tag -a v0.2.5 -m "Release v0.2.5"
+git push origin v0.2.5
+```
+
+Pushing the tag starts the `Release` workflow. It:
+
+1. runs the complete test suite and ShellCheck;
+2. generates release notes with git-cliff;
+3. builds reproducible `.tar.gz` and `.zip` archives;
+4. validates their exact contents;
+5. creates `SHA256SUMS` and verifies every checksum;
+6. publishes the GitHub release and assets; and
+7. opens a pull request to update `CHANGELOG.md` on `main`.
+
+## 5. Verify and synchronise
+
+On the GitHub release page, confirm:
+
+- the release is attached to the intended tag and commit;
+- the release notes are present;
+- `chdtool`, both archives, and `SHA256SUMS` are attached; and
+- downloaded assets pass `sha256sum --check SHA256SUMS`.
+
+Merge the automated changelog pull request after its checks pass. Finally, open
+a backport pull request from `main` into `develop` so both long-lived branches
+contain the release merge, tag history, and generated changelog. Resolve the
+backport before starting further release work.
+
+## Failure handling
+
+- If preflight checks or local packaging fail, fix them on the release branch;
+ do not tag.
+- If the tag workflow fails before publishing a release, inspect the failed job,
+ fix the problem through the normal branch and pull-request flow, then decide
+ whether the unused tag can be deleted and recreated.
+- Never move or reuse a tag after a GitHub release has been published. Correct a
+ published release with a new patch version.
+- Do not upload hand-built replacement assets to an automated release; rerun a
+ corrected workflow under a new version so provenance and checksums remain
+ consistent.
diff --git a/chdtool b/chdtool
deleted file mode 100755
index 2253c88..0000000
--- a/chdtool
+++ /dev/null
@@ -1,1272 +0,0 @@
-#!/usr/bin/env bash
-
-set -Eeuo pipefail
-set +H
-script_start_time=$(date +%s)
-shopt -s nullglob
-shopt -s extglob
-
-USAGE="Usage: $0 [--keep-originals|-k] [--recursive|-r] [--dry-run|-n] [--file-tee|-F|--no-file-tee|-N] "
-KEEP_ORIGINALS=false
-RECURSIVE=false
-DRY_RUN=false
-INPUT_DIR=""
-IS_RAM_DISK=false
-RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)-$$}"
-CHDMAN_MSG_LEVEL="${CHDMAN_MSG_LEVEL:-DEBUG}"
-case "${CHDMAN_MSG_LEVEL^^}" in
- DEBUG|INFO|WARN|ERROR) ;; # OK
- *) CHDMAN_MSG_LEVEL="INFO" ;; # fallback
-esac
-
-# Manual parsing to support long options
-while [[ $# -gt 0 ]]; do
- case "$1" in
- --keep-originals|-k)
- KEEP_ORIGINALS=true; shift ;;
- --recursive|-r)
- RECURSIVE=true; shift ;;
- --dry-run|-n)
- DRY_RUN=true; shift ;;
- --file-tee|-F)
- LOG_TEE_FILE=1; shift ;;
- --no-file-tee|-N)
- LOG_TEE_FILE=0; shift ;;
- -*)
- echo "โ Unknown option: $1" >&2
- echo "$USAGE" >&2; exit 1 ;;
- *)
- if [[ -z "$INPUT_DIR" ]]; then
- INPUT_DIR="${1%/}" # Remove trailing slash
- else
- echo "โ Unexpected extra argument: $1" >&2
- echo "$USAGE" >&2; exit 1
- fi
- shift ;;
- esac
-done
-
-if [[ -z "$INPUT_DIR" ]]; then
- echo "$USAGE" >&2; exit 1
-fi
-if [[ ! -d "$INPUT_DIR" ]]; then
- echo "โ Input directory does not exist or is not a directory: $INPUT_DIR" >&2
- exit 1
-fi
-
-# Use a disk-based temp directory to avoid filling up RAM
-TMPDIR="/var/tmp/chdtool"
-mkdir -p "$TMPDIR"
-
-LOGFILE="logs/chd_conversion_$(date +%Y%m%d_%H%M%S).log"
-
-# --- Pluggable logging: console/file/syslog/journald (auto) -------------------
-# Control via env vars (no root needed to write to journald/syslog):
-# LOG_DEST=auto|console|file|syslog|journald
-# LOGFILE=/path/to/file (used when LOG_DEST=file; defaults to ./logs/โฆ)
-# LOG_TAG=chdtool
-LOG_DEST="${LOG_DEST:-auto}"
-LOG_TAG="${LOG_TAG:-chdtool}"
-
-# Mirror policy: auto (TTY only), 1 (always), 0 (never)
-LOG_TEE_CONSOLE="${LOG_TEE_CONSOLE:-auto}"
-
-# New: mirror-to-file policy for journald/syslog backends.
-# 1|true|yes (default) โ also append to $LOGFILE
-# 0|false|no โ do not write a file when using journald/syslog
-LOG_TEE_FILE="${LOG_TEE_FILE:-1}"
-
-__should_mirror_file() {
- case "${LOG_TEE_FILE}" in
- 1|true|yes) return 0 ;;
- 0|false|no) return 1 ;;
- *) return 0 ;; # default to on
- esac
-}
-
-_detect_backend() {
- case "$LOG_DEST" in
- journald) echo journald ;;
- syslog) echo syslog ;;
- file) echo file ;;
- console) echo console ;;
- auto)
- if [[ -S /run/systemd/journal/socket ]] && command -v systemd-cat >/dev/null 2>&1; then
- echo journald
- elif command -v logger >/dev/null 2>&1; then
- echo syslog
- else
- echo file
- fi
- ;;
- *) echo file ;;
- esac
-}
-LOG_BACKEND="$(_detect_backend)"
-
-# add RUN_ID tag only for journald/syslog
-if [[ "$LOG_BACKEND" == journald || "$LOG_BACKEND" == syslog ]]; then
- LOG_TAG="${LOG_TAG}[${RUN_ID}]"
-fi
-
-# ensure the logfile directory exists when we might write to it
-if [[ "$LOG_BACKEND" == file || "$LOG_BACKEND" == console ]] || __should_mirror_file; then
- mkdir -p -- "$(dirname -- "$LOGFILE")"
-fi
-
-__should_mirror_console() {
- case "${LOG_TEE_CONSOLE}" in
- 1|true|yes) return 0 ;;
- 0|false|no) return 1 ;;
- auto) [[ -t 2 ]] && return 0 || return 1 ;; # use fd 2
- *) return 1 ;;
- esac
-}
-
-__console_print() {
- # $1=ts, $2=level, $3=message (may be multiline)
- local ts="$1" lvl="$2" msg="$3"
- while IFS= read -r line; do
- printf '[%s] %s: %s\n' "$ts" "$lvl" "$line" >&2
- done <<< "$msg"
-}
-
-# Internal emitter: $1=LEVEL (INFO/WARN/ERROR/DEBUG), $2...=message
-_emit_log() {
- local lvl="$1"; shift || true
- local msg="${*:-}"
- local ts; ts="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
-
- local pri=info
- case "$lvl" in
- DEBUG) pri=debug ;;
- INFO) pri=info ;;
- WARN) pri=warning ;;
- ERROR) pri=err ;;
- esac
-
- case "$LOG_BACKEND" in
- journald)
- {
- echo "LEVEL=$lvl"
- echo "RUN_ID=$RUN_ID"
- while IFS= read -r line; do echo "$line"; done <<< "$msg"
- } | systemd-cat --priority="$pri" --identifier="$LOG_TAG"
- # Also append to file if enabled
- if __should_mirror_file; then
- while IFS= read -r line; do
- printf '[%s] %s: %s\n' "$ts" "$lvl" "$line" >> "$LOGFILE"
- done <<< "$msg"
- fi
- __should_mirror_console && __console_print "$ts" "$lvl" "$msg"
- ;;
- syslog)
- while IFS= read -r line; do
- logger -p "user.$pri" -t "$LOG_TAG" -- "$lvl: $line"
- done <<< "$msg"
- # Also append to file if enabled
- if __should_mirror_file; then
- while IFS= read -r line; do
- printf '[%s] %s: %s\n' "$ts" "$lvl" "$line" >> "$LOGFILE"
- done <<< "$msg"
- fi
- __should_mirror_console && __console_print "$ts" "$lvl" "$msg"
- ;;
- file)
- while IFS= read -r line; do
- printf '[%s] %s: %s\n' "$ts" "$lvl" "$line" >> "$LOGFILE"
- done <<< "$msg"
- __should_mirror_console && __console_print "$ts" "$lvl" "$msg"
- ;;
- console|*)
- while IFS= read -r line; do
- printf '[%s] %s: %s\n' "$ts" "$lvl" "$line" | tee -a "$LOGFILE"
- done <<< "$msg"
- ;;
- esac
-}
-
-# Public logger. Backwards-compatible: `log "message"` still works.
-# Optional levels: `log INFO "message"`, `log WARN "msg"`, etc.
-LOG_LEVEL_THRESHOLD="${LOG_LEVEL_THRESHOLD:-INFO}" # DEBUG|INFO|WARN|ERROR
-__level_num() { case "$1" in DEBUG) echo 10;; INFO) echo 20;; WARN) echo 30;; ERROR) echo 40;; *) echo 999;; esac; }
-
-log() {
- local lvl="INFO"
- case "${1:-}" in DEBUG|INFO|WARN|ERROR) lvl="$1"; shift ;; esac
- if (( $(__level_num "$lvl") < $(__level_num "$LOG_LEVEL_THRESHOLD") )); then
- return 0
- fi
- _emit_log "$lvl" "$*"
-}
-
-log INFO "๐ Script started, input dir: $INPUT_DIR"
-[[ "$RECURSIVE" == true ]] && log INFO "๐ Recursive mode enabled โ scanning subdirectories"
-[[ "$DRY_RUN" == true ]] && log INFO "๐งช Dry-run mode: no files will be written, moved, or deleted"
-
-is_in_list() {
- local needle="$1"; shift
- local x
- for x in "$@"; do [[ "$x" == "$needle" ]] && return 0; done
- return 1
-}
-
-required_commands=(chdman unzip unrar 7z stat awk)
-archive_exts=(zip rar 7z 7zip)
-disc_exts=(iso cue gdi ccd)
-all_exts=("${archive_exts[@]}" "${disc_exts[@]}")
-
-get_chd_basename() {
- local file="$1"
- basename "${file%.*}"
-}
-
-build_find_expr() {
- local patterns=("$@")
- local expr=()
- for ext in "${patterns[@]}"; do
- expr+=("-iname" "*.${ext}" "-o")
- done
- unset "expr[${#expr[@]}-1]" # Remove trailing -o
- echo "${expr[@]}"
-}
-
-build_ext_regex() {
- local exts=("$@")
- local regex="\."
- regex+="($(IFS='|'; echo "${exts[*]}"))\$"
- echo "$regex"
-}
-
-for cmd in "${required_commands[@]}"; do
- if ! command -v "$cmd" >/dev/null 2>&1; then
- if [[ "$DRY_RUN" == true ]]; then
- log WARN "๐งช (dry-run) '$cmd' not found โ would be required for a real run"
- else
- log ERROR "โ Error: Required command '$cmd' not found. Please install it and ensure it's in your PATH."
- exit 1
- fi
- fi
-done
-
-if command -v chdman >/dev/null 2>&1; then
- chdman_version="$(chdman --help 2>&1 | head -n 1 || true)"
- log INFO "โน๏ธ Using $chdman_version"
-else
- if [[ "$DRY_RUN" == true ]]; then
- log WARN "๐งช (dry-run) 'chdman' not found โ would be required for a real run"
- else
- log ERROR "โ 'chdman' not found in PATH"
- exit 1
- fi
-fi
-
-# Detect 'createdvd' capability (newer chdman versions)
-CHDMAN_HAS_CREATEDVD=false
-if command -v chdman >/dev/null 2>&1; then
- set +o pipefail
- log DEBUG "chdman -help output: $(chdman -help 2>&1)"
- if chdman -help 2>&1 | grep -qiE 'createdvd'; then
- log DEBUG "Found 'createdvd' in chdman -help output"
- CHDMAN_HAS_CREATEDVD=true
- else
- log DEBUG "Did NOT find 'createdvd' in chdman -help output"
- CHDMAN_HAS_CREATEDVD=false
- fi
- set -o pipefail
- log DEBUG "โน๏ธ chdman createdvd support: $CHDMAN_HAS_CREATEDVD"
-else
- # In dry-run without chdman, just log at DEBUG and keep default false
- [[ "$DRY_RUN" == true ]] && log DEBUG "โน๏ธ chdman not present (dry-run); assuming no createdvd"
-fi
-
-total_original_size=0
-total_chd_size=0
-archives_processed=0
-chds_created=0
-failures=0
-
-human_readable() {
- local bytes=$1
- if [[ $bytes -lt 1024 ]]; then
- echo "${bytes} B"
- elif [[ $bytes -lt 1048576 ]]; then
- echo "$((bytes / 1024)) KB"
- elif [[ $bytes -lt 1073741824 ]]; then
- echo "$((bytes / 1048576)) MB"
- else
- echo "$((bytes / 1073741824)) GB"
- fi
-}
-
-format_duration() {
- local total_seconds=$1
- local minutes=$((total_seconds / 60))
- local seconds=$((total_seconds % 60))
- if (( minutes > 0 )); then
- printf "%dm %02ds" "$minutes" "$seconds"
- else
- printf "%ds" "$seconds"
- fi
-}
-
-get_file_size() {
- if stat --version >/dev/null 2>&1; then
- stat -c%s "$1"
- else
- stat -f%z "$1"
- fi
-}
-
-check_temp_storage() {
- local tmp_dir="$1"
- local fs_type
- fs_type=$(df -T "$tmp_dir" | awk 'NR==2 {print $2}')
-
- if [[ "$fs_type" == "tmpfs" ]]; then
- local tmp_limit
- tmp_limit=$(df -h "$tmp_dir" | awk 'NR==2 {print $4}')
- log WARN "โ ๏ธ $tmp_dir is a RAM disk (tmpfs). Extracted ISOs will consume physical RAM!"
- log WARN "๐ก Available space in RAM disk: $tmp_limit"
-
- # Force thread reduction if we are in a RAM disk
- IS_RAM_DISK=true
- fi
-}
-
-check_temp_storage "$TMPDIR"
-
-# ---------- chdman progress handling ----------
-# Config: PROGRESS_STYLE=auto|bar|line|none ; default: auto (TTY -> bar, non-TTY -> none)
-PROGRESS_STYLE_DEFAULT="auto"
-PROGRESS_THROTTLE_MS=250 # reduce flicker
-PROGRESS_MARGIN=28 # spare columns to avoid wrap (emoji-width safety)
-
-# Print N copies of a char
-_repeat_char() { local n=$1 c=$2 out=""; while (( n-- > 0 )); do out+="$c"; done; printf "%s" "$out"; }
-
-# Draw a single-line status (bar or line) to stderr, staying on one row.
-PROGRESS_BAR_MAX=${PROGRESS_BAR_MAX:-40}
-
-_term_print() {
- if [[ -t 2 && -w /dev/tty ]]; then
- printf "%b" "$1" > /dev/tty
- else
- printf "%b" "$1" >&2
- fi
-}
-
-_draw_progress() {
- local phase="$1" pct="$2" ratio="$3"
- local style="${PROGRESS_STYLE:-$PROGRESS_STYLE_DEFAULT}"
-
- if [[ "$style" == "auto" ]]; then
- if [[ -t 2 ]]; then style="bar"; else style="none"; fi
- fi
- [[ "$style" == "none" ]] && return 0
-
- local cols="${COLUMNS:-}"
- [[ -z "$cols" && -t 2 ]] && cols=$(tput cols 2>/dev/null || echo 80)
- [[ -z "$cols" ]] && cols=80
-
- local left="โณ ${phase} ${pct}%"
- [[ -n "$ratio" ]] && left+=" (r=${ratio}%)"
-
- local text
- if [[ "$style" == "line" ]]; then
- text="$left"
- else
- local margin=${PROGRESS_MARGIN:-20}
- local barw=$(( cols - (${#left} + margin) ))
- local cap=${PROGRESS_BAR_MAX:-40}
- (( barw > cap )) && barw=$cap
- (( barw < 10 )) && barw=10
- local scaled; scaled="$(awk -v p="$pct" -v w="$barw" 'BEGIN{ printf "%.0f",(p/100.0)*w }')" || scaled=0
- [[ -z "$scaled" ]] && scaled=0
- (( scaled < 0 )) && scaled=0
- (( scaled > barw )) && scaled=$barw
- local filled=$scaled
- local empty=$(( barw - filled ))
- text="$left [$(_repeat_char "$filled" "#")$(_repeat_char "$empty" "-")]"
- fi
-
- (( ${#text} > cols-2 )) && text="${text:0:cols-2}"
-
- # CR + clear + draw with autowrap temporarily off; NO newline
- _term_print "\r\033[2K\033[?7l${text}\033[?7h"
-}
-
-_now_ms() {
- local s
- if s="$(date +%s%3N 2>/dev/null)"; then
- printf '%s' "$s"
- else
- printf '%s' $(( $(date +%s) * 1000 ))
- fi
-}
-
-# Generic chdman progress filter
-# Use with: PHASE_DEFAULT="Converting" chdman createcd โฆ | _chdman_progress_filter
-# or: PHASE_DEFAULT="Verifying" chdman verify โฆ | _chdman_progress_filter
-_chdman_progress_filter() {
- # Always re-enable autowrap on exit/interrupt; return non-zero on INT/TERM
- _restore_wrap() { _term_print "\033[?7h"; }
- trap '_restore_wrap; return 130' INT TERM
- trap _restore_wrap EXIT
-
- local last_draw=0 phase="${PHASE_DEFAULT:-Compressing}" ratio="" progress_active=0 ms
-
- while IFS= read -d $'\r' -r line || [[ -n "$line" ]]; do
- if [[ "$line" =~ ([0-9]+([.][0-9]+)?)%[[:space:]]*complete ]]; then
- local pct="${BASH_REMATCH[1]}"
- [[ "$line" =~ ^([A-Za-z]+), ]] && phase="${BASH_REMATCH[1]}"
- if [[ "$line" =~ \(ratio=([0-9]+([.][0-9]+)?)%\) ]]; then ratio="${BASH_REMATCH[1]}"; else ratio=""; fi
-
- ms="$(_now_ms)"
- if (( ms - last_draw >= PROGRESS_THROTTLE_MS )); then
- _draw_progress "$phase" "$pct" "$ratio"
- last_draw=$ms
- progress_active=1
- fi
- continue
- fi
-
- # Ignore obvious chopped progress fragments
- if [[ "$line" =~ ^[[:space:]]*([A-Za-z]+,)?[[:space:]]*$ ]] || \
- [[ "$line" =~ ^[[:space:]]*[0-9]+([.][0-9]+)?[[:space:]]*$ ]]; then
- continue
- fi
-
- log "$CHDMAN_MSG_LEVEL" "$line"
- done
-
- # At EOF: end the progress line neatly with a single newline
- (( progress_active )) && _term_print "\r\033[2K\n"
-}
-
-# ---------- M3U (per-iteration) helpers ----------
-# Trim leading/trailing spaces/dots/underscores/dashes
-trim() {
- local s="$*"
- s="${s##+([[:space:]._-])}"
- s="${s%%+([[:space:]._-])}"
- printf "%s" "$s"
-}
-
-# --- helpers for robust parsing ---
-normalize_for_parse() {
- # Normalize to make matching easier (full-width โ ASCII, unify spaces/dashes)
- local s="$*"
- if command -v perl >/dev/null 2>&1; then
- s="$(printf '%s' "$s" | perl -CS -Mutf8 -pe '
- # full-width digits โ ASCII
- tr/\x{FF10}-\x{FF19}/0-9/;
- # full-width parens/brackets โ ASCII
- tr/\x{FF08}\x{FF09}/()/; # ๏ผ ๏ผ
- tr/\x{FF3B}\x{FF3D}/[]/; # ๏ผป ๏ผฝ
- # ideographic space โ normal space
- tr/\x{3000}/ /;
- # unify dashes to ASCII hyphen
- s/[-โโโโ]/-/g;
- ')"
- fi
- printf '%s' "$s"
-}
-
-tidy_base() {
- # Trim + drop lingering separators/brackets at the end
- local s="$*"
- s="$(trim "$s")"
- if command -v perl >/dev/null 2>&1; then
- s="$(printf '%s' "$s" | perl -CS -Mutf8 -pe 's/[ \t._-]*[([{๏ผ๏ผป๏ฝ]*\s*$//')"
- else
- s="$(printf '%s' "$s" | sed -E 's/[[:space:]._-]*[\(\[\{]+[[:space:]]*$//')"
- fi
- printf '%s' "$s"
-}
-
-letter_to_num() {
- # Aโ1, Bโ2, โฆ Zโ26
- local L="${1:-}"
- [[ -z "$L" ]] && { echo ""; return 1; }
- L="${L^^}"
- printf '%d\n' $(( $(printf '%d' "'${L:0:1}") - 64 ))
-}
-
-# Parse disc info from a base name (no extension).
-# On success, echoes "|" and returns 0; else returns 1.
-parse_disc_info() {
- local name="$1"
- local name_norm; name_norm="$(normalize_for_parse "$name")"
-
- # Pattern set 1: Disc/CD/Disk/GD(-ROM)? with optional separator or none:
- # e.g., "Title Disc2", "Title (CD-2)", "Title [Disk02]", "Title GD-ROM 3", "Title Disc 01"
- # ERE (bash) has no (?: ). Keep groups simple and predictable.
- local re_core='([Dd]isc|[Cc][Dd]|[Dd]isk|[Gg][Dd]|[Gg][Dd]-[Rr][Oo][Mm])'
- local re_num='([0-9]{1,3})'
- local re_sep='[[:space:]]*[-_.]?[[:space:]]*'
- # For the compact/union pattern, keep it a single capturing group:
- local re_label_union='([Dd]isc|[Cc][Dd]|[Dd]isk|[Gg][Dd]|[Gg][Dd]-[Rr][Oo][Mm]|[Vv]ol|[Vv]olume|[Pp]art|[Pp]t\.?)'
-
-
- if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*\(?$re_core$re_sep$re_num\)?([[:space:]]*.*)?$ ]]; then
- local base="${BASH_REMATCH[1]}"
- local num="${BASH_REMATCH[3]}" # (1=label,2=sep? depends on grouping; ensure index)
- # Because of our grouping above, indexes are:
- # 1=prefix, 2=label, 3=number, 4=tail
- base="$(tidy_base "$base")"
- [[ -n "$base" && -n "$num" ]] && { echo "$base|$num"; return 0; }
- fi
-
- # Pattern set 2: Vol/Volume, Part/Pt
- if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*\(?([Vv]ol|[Vv]olume|[Pp]art|[Pp]t\.?)$re_sep$re_num\)?([[:space:]]*.*)?$ ]]; then
- local base="${BASH_REMATCH[1]}"
- local num="${BASH_REMATCH[3]}"
- base="$(tidy_base "$base")"
- [[ -n "$base" && -n "$num" ]] && { echo "$base|$num"; return 0; }
- fi
-
- # Pattern set 3: Side A/B/Cโฆ (map letters โ 1/2/3โฆ)
- if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*\(?([Ss]ide)[[:space:]]*([A-Za-z])\)?([[:space:]]*.*)?$ ]]; then
- local base="${BASH_REMATCH[1]}"
- local letter="${BASH_REMATCH[3]}"
- local num; num="$(letter_to_num "$letter")" || num=""
- base="$(tidy_base "$base")"
- [[ -n "$base" && -n "$num" ]] && { echo "$base|$num"; return 0; }
- fi
-
- # Pattern set 4: "1 of 2" / "1/2"
- if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*\(?([0-9]+)[[:space:]]*(?:of|/)[[:space:]]*[0-9]+\)?([[:space:]]*.*)?$ ]]; then
- local base="${BASH_REMATCH[1]}"
- local num="${BASH_REMATCH[2]}"
- base="$(tidy_base "$base")"
- [[ -n "$base" && -n "$num" ]] && { echo "$base|$num"; return 0; }
- fi
-
- # Pattern set 5: compact forms WITHOUT spaces/brackets:
- # "Title Disc02", "Title CD2", "Title Vol.2", "Title Pt.3"
- if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*$re_label_union$re_sep$re_num([[:space:]]*.*)?$ ]]; then
- local base="${BASH_REMATCH[1]}"
- local num="${BASH_REMATCH[3]}"
- base="$(tidy_base "$base")"
- [[ -n "$base" && -n "$num" ]] && { echo "$base|$num"; return 0; }
- fi
-
- return 1
-}
-
-# Make a filename safe across Linux/macOS/Windows shares.
-# - normalizes Unicode if `uconv` is available
-# - removes control chars
-# - replaces / \ : * ? " < > | with '-'
-# - collapses whitespace; trims ends
-# - optional Windows reserved-name guard (SANITIZE_CROSSPLATFORM=0 to disable)
-# - truncates to a safe length (default 200 chars)
-sanitize_filename() {
- local s="$1"
-
- # Unicode NFKC normalization if ICU's uconv exists (nice-to-have)
- if command -v uconv >/dev/null 2>&1; then
- s="$(printf '%s' "$s" | uconv -x any-nfkc 2>/dev/null || printf '%s' "$s")"
- fi
-
- # Strip control chars
- s="$(printf '%s' "$s" | tr -d '\000-\037\177')"
-
- # Replace problematic characters and tidy spaces/dashes
- s="$(printf '%s' "$s" \
- | sed -E 's/[\/\\:*?"<>|]/-/g; s/[[:space:]]+/ /g; s/[[:space:]]*-[[:space:]]*/ - /g')"
-
- # Trim leading/trailing separators/spaces
- s="$(printf '%s' "$s" | sed -E 's/^[[:space:]._-]+//; s/[[:space:]._-]+$//')"
-
- # Guard Windows reserved basenames for SMB users
- if [[ "${SANITIZE_CROSSPLATFORM:-1}" == 1 ]]; then
- case "${s^^}" in
- CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]) s="_$s";;
- esac
- fi
-
- # Length cap (characters). 200 is comfortably under 255-byte limits.
- local max=${FILENAME_MAX_CHARS:-200}
- if (( ${#s} > max )); then
- s="${s:0:max}"
- s="$(printf '%s' "$s" | sed -E 's/[[:space:]._-]+$//')" # re-trim tail
- fi
-
- [[ -z "$s" ]] && s="Set"
- printf '%s' "$s"
-}
-
-# Build/refresh a single M3U for a given base in one directory.
-# We only consider CHDs that share the parsed base (case-insensitive).
-generate_m3u_for_base() {
- local outdir="$1"
- local base="$2"
- local -a members=()
-
- local f stem p p_base
- while IFS= read -r -d '' f; do
- stem="${f##*/}"; stem="${stem%.chd}"
- if p="$(parse_disc_info "$stem")"; then
- p_base="${p%%|*}"
- if [[ "${p_base,,}" == "${base,,}" ]]; then
- members+=("$f")
- fi
- fi
- done < <(find "$outdir" -maxdepth 1 -type f -iname "*.chd" -print0)
-
- if (( ${#members[@]} < 2 )); then
- log DEBUG "โน๏ธ Not generating M3U - fewer than two CHDs found for base: $base"
- return 0
- fi
-
- # Sort by parsed disc number
- local sorted
- sorted="$(
- for f in "${members[@]}"; do
- stem="${f##*/}"; stem="${stem%.chd}"
- p="$(parse_disc_info "$stem")"
- echo "${p##*|}|$f"
- done | LC_ALL=C sort -t'|' -k1,1n | cut -d'|' -f2
- )"
- mapfile -t members <<< "$sorted"
-
- # Write idempotently (relative file names in body)
- # inside generate_m3u_for_base, after computing safe_base
- local safe_base; safe_base="$(sanitize_filename "$base")"
- local legacy_path="$outdir/${base}.m3u"
- local m3u_path="$outdir/${safe_base}.m3u"
-
- # migrate legacy โ sanitized (only if sanitized doesn't exist yet)
- if [[ -f "$legacy_path" && ! -f "$m3u_path" ]]; then
- if [[ "$DRY_RUN" == true ]]; then
- log INFO "๐งช (dry-run) Would rename legacy M3U โ sanitized: $(basename "$legacy_path") โ $(basename "$m3u_path")"
- else
- mv -f -- "$legacy_path" "$m3u_path"
- log INFO "๐งน Renamed legacy M3U โ sanitized: $(basename "$legacy_path") โ $(basename "$m3u_path")"
- fi
- fi
-
- if [[ "$DRY_RUN" == true ]]; then
- log INFO "๐งช (dry-run) Would (re)write M3U: $m3u_path with ${#members[@]} lines"
- else
- local tmp_m3u="$m3u_path.tmp"
- : > "$tmp_m3u"
- for f in "${members[@]}"; do
- printf '%s\n' "$(basename "$f")" >> "$tmp_m3u"
- done
- if [[ -f "$m3u_path" ]] && cmp -s "$tmp_m3u" "$m3u_path"; then
- rm -f "$tmp_m3u"
- log INFO "๐งพ M3U up-to-date: $m3u_path"
- else
- # Remember pre-move existence to log Created vs Updated correctly
- local _m3u_existed=false
- [[ -f "$m3u_path" ]] && _m3u_existed=true
- mv -f -- "$tmp_m3u" "$m3u_path"
- if [[ "$_m3u_existed" == true ]]; then
- log INFO "๐ Updated M3U: $m3u_path"
- else
- log INFO "๐ Created M3U: $m3u_path"
- fi
- fi
- fi
-}
-
-# Decide if $chd_base looks like a multi-disc title and (re)generate its M3U now.
-maybe_generate_m3u_for() {
- local chd_base="$1" # e.g. "Virtua Fighter (Disc 2)"
- local outdir="$2" # directory where CHDs live
- local parsed
- if ! parsed="$(parse_disc_info "$chd_base")"; then
- log DEBUG "โน๏ธ Not generating M3U - CHD not part of multi-disc set: $chd_base"
- return 0
- fi
- local base="${parsed%%|*}"
- log DEBUG "๐ M3U check โ base: $base"
-
- if [[ "$DRY_RUN" == true ]]; then
- log INFO "๐งช (dry-run) Would generate/update M3U for base: $base"
- else
- generate_m3u_for_base "$outdir" "$base"
- fi
-}
-# ---------- end M3U helpers ----------
-# --- Global interrupt + cleanup handling --------------------------------------
-# Track temp dirs created during processing so we can clean them on SIGINT/TERM
-declare -a TEMP_DIRS=()
-
-_restore_wrap_global() {
- # Make sure terminal autowrap is re-enabled and the progress line cleared
- # (safe to emit even if no progress was showing)
- _term_print "\r\033[2K\033[?7h\n"
-}
-
-cleanup_all() {
- # Remove any temp dirs that might still exist
- if ((${#TEMP_DIRS[@]})); then
- for d in "${TEMP_DIRS[@]}"; do
- if [[ -n "$d" && -d "$d" ]]; then
- rm -rf -- "$d"
- log INFO "๐งน Cleaned up temp dir: $d"
- fi
- done
- fi
-
- # Remove our known temp files in the working directory tree
- if [[ -n "$INPUT_DIR" && -d "$INPUT_DIR" ]]; then
- while IFS= read -r -d '' f; do
- rm -f -- "$f"
- log INFO "๐๏ธ Removed leftover tmp file: $f"
- done < <(find "$INPUT_DIR" -type f \( -name '*.chd.tmp' -o -name '*.m3u.tmp' \) -print0)
- fi
-
- # Remove verify scratch files in the new temp directory
- while IFS= read -r -d '' f; do
- rm -f -- "$f"
- log INFO "๐๏ธ Removed verify scratch: $f"
- done < <(find "$TMPDIR" -maxdepth 1 -type f -name 'chdverify_*' -print0 2>/dev/null || true)
-}
-
-_on_interrupt() {
- # One place to handle Ctrl-C/TERM: restore terminal, clean, then exit(130)
- _restore_wrap_global
- log WARN "๐ Interrupted โ cleaning up and exitingโฆ"
- trap - EXIT
- cleanup_all
- exit 130
-}
-
-# Ctrl-C (INT) and TERM should both stop the whole script
-trap _on_interrupt INT TERM
-trap cleanup_all EXIT
-
-verify_chds() {
- local outdir="$1"; shift
- local chds=("$@")
- local all_verified=true
-
- if [[ "$DRY_RUN" == true ]]; then
- for chd in "${chds[@]}"; do
- log INFO "๐งช (dry-run) Would verify: $outdir/$chd"
- done
- return 0
- fi
-
- for chd in "${chds[@]}"; do
- local chd_path="$outdir/$chd"
- if [[ ! -f "$chd_path" ]]; then
- all_verified=false
- break
- fi
-
- local verify_exit_code=0
- local tmpout
- tmpout="$(mktemp -p "$TMPDIR" chdverify_XXXXXX)"
-
- log INFO "๐ Verifying: $chd_path"
- if [[ -t 2 && "${PROGRESS_STYLE:-$PROGRESS_STYLE_DEFAULT}" != "none" ]]; then
- # TTY: show single-line progress, capture full output to tmp for analysis
- if PHASE_DEFAULT="Verifying" "${CHDMAN_BIN:-chdman}" verify -i "$chd_path" 2>&1 \
- | tee "$tmpout" \
- | _chdman_progress_filter
- then
- verify_exit_code=0
- else
- verify_exit_code=$?
- fi
- else
- # Non-TTY: no progress UI, still capture output
- if "${CHDMAN_BIN:-chdman}" verify -i "$chd_path" 2>&1 | tee "$tmpout" >/dev/null
- then
- verify_exit_code=0
- else
- verify_exit_code=$?
- fi
- fi
-
- if [[ $verify_exit_code -ne 0 ]]; then
- # Summarize failure reasons (quietly)
- local failure_reasons
- failure_reasons="$(grep -iE 'error|fail|invalid|corrupt' "$tmpout" || true)"
- [[ -n "$failure_reasons" ]] && log DEBUG " Failure details: $failure_reasons"
-
- log WARN "โ ๏ธ Verification failed on first try for: $chd_path"
- log INFO "โณ Retrying after delay..."
- sleep 2
-
- # Retry with a fresh capture file
- : > "$tmpout"
- log INFO "๐ Verifying: $chd_path"
- if [[ -t 2 && "${PROGRESS_STYLE:-$PROGRESS_STYLE_DEFAULT}" != "none" ]]; then
- if PHASE_DEFAULT="Verifying" "${CHDMAN_BIN:-chdman}" verify -i "$chd_path" 2>&1 \
- | tee "$tmpout" \
- | _chdman_progress_filter
- then
- log INFO "โ
Verified on retry: $chd_path"
- rm -f -- "$tmpout"
- continue
- fi
- else
- if "${CHDMAN_BIN:-chdman}" verify -i "$chd_path" 2>&1 | tee "$tmpout" >/dev/null
- then
- log INFO "โ
Verified on retry: $chd_path"
- rm -f -- "$tmpout"
- continue
- fi
- fi
-
- # Still failed: log concise reasons and clean up
- failure_reasons="$(grep -iE 'error|fail|invalid|corrupt' "$tmpout" || true)"
- [[ -n "$failure_reasons" ]] && log DEBUG " Failure details: $failure_reasons"
- failures=$((failures + 1))
- log ERROR "โ Verification failed on retry for: $chd_path โ deleting"
- rm -f -- "$chd_path"
- rm -f -- "$tmpout"
- all_verified=false
- break
- else
- log INFO "โ
Verified CHD: $chd_path"
- rm -f -- "$tmpout"
- fi
- done
-
- $all_verified && return 0 || return 1
-}
-
-validate_cue_file() {
- local cue_file="$1"
- local cuedir
- cuedir="$(dirname "$cue_file")"
- local cue_basename
- cue_basename="$(basename "$cue_file")"
- local missing=0
-
- declare -A file_map
- while IFS= read -r -d '' f; do
- file_map["${f,,}"]="$f"
- done < <(find "$cuedir" -maxdepth 1 -type f -printf '%f\0')
-
- while IFS= read -r line; do
- if [[ "$line" =~ ^[[:space:]]*FILE[[:space:]]+\"([^\"]+)\" ]]; then
- local ref="${BASH_REMATCH[1]}"
- local ref_norm="${ref//\\//}"
- local ref_basename; ref_basename="$(basename "$ref_norm")"
- local ref_lower="${ref_basename,,}"
-
- [[ "$ref_lower" == "${cue_basename,,}" ]] && continue
-
- if [[ "$ref_lower" == *.mp3 || "$ref_lower" == *.wav ]]; then
- log WARN "โ ๏ธ CUE file references unsupported audio format: $ref_basename"
- fi
- if [[ "$ref_norm" == /* || "$ref_norm" == *".."* ]]; then
- log WARN "โ ๏ธ Skipping unsafe external path in CUE: $ref_basename"
- continue
- fi
- if [[ -z "${file_map["$ref_lower"]:-}" ]]; then
- log ERROR "โ Missing referenced file in CUE: $ref_basename (required by $cue_file)"
- missing=1
- fi
- fi
- done < "$cue_file"
-
- return $missing
-}
-
-detect_disc_type() {
- local img="$1"
- local ext
- ext="${img##*.}"; ext="${ext,,}"
- local sz
- sz=$(get_file_size "$img" 2>/dev/null || echo 0)
-
- #1. Size-based "hard limits"
- # If it's > 1GB, it's a DVD/PS2 regardless of what the header says (some PS2 games have weird headers that look like CDs)
- local is_large_disc=false
- (( sz >= 1000000000 )) && is_large_disc=true
-
- #2. Immediate CD extensions - CUE/CCD/GDI are CD-type by definition
- case "$ext" in
- cue|ccd|gdi) echo "cd"; return 0 ;;
- esac
-
- #3. Console Fingerprinting
- # Reading the first 64KB covers Volume Descriptors and Boot Headers
- local header
- header=$(head -c 65535 "$img" 2>/dev/null | tr -d '\0')
-
- case "$header" in
- *"PLAYSTATION 2"*|*"NTSC-U/C PS2 DVD"*) echo "ps2"; return 0 ;;
- *"PLAYSTATION "*|*"NTSC-U/C PS1 CD"*)
- # If it says PS1 but it's >1GB, it's a mislabeled PS2 DVD
- if [[ "$is_large_disc" == true ]]; then
- log WARN "โ ๏ธ Header indicates PS1 but size suggests PS2 DVD. Treating as PS2: $img"
- echo "ps2"
- else
- echo "ps1"
- fi
- return 0 ;;
- *"SEGA MEGA-CD"*) echo "segacd"; return 0 ;;
- *"SEGA SEGAKATANA"*) echo "dreamcast"; return 0 ;;
- *"SEGA SEGASATURN"*) echo "saturn"; return 0 ;;
- *"PSP GAME"*|*"UMD VIDEO"*) echo "psp"; return 0 ;;
- esac
-
- #4. UDF/ISO logic fallback
- if [[ "$ext" == "iso" ]]; then
- if command -v file >/dev/null 2>&1; then
- local sig
- sig="$(file -b -- "$img" 2>/dev/null || true)"
- [[ "$sig" == *"UDF filesystem"* ]] && { echo "dvd"; return 0; }
- else
- if dd if="$img" bs=2048 skip=256 count=32 status=none 2>/dev/null \
- | tr -d '\0' | grep -qE 'NSR0(2|3)?'; then
- echo "dvd"; return 0
- fi
- fi
-
- # Size heuristic: โฅ ~1 GB โ likely DVD; otherwise CD
- local sz
- sz=$(get_file_size "$img")
- (( sz >= 1000000000 )) && { echo "dvd"; return 0; }
- fi
-
- # Unknown extension โ default to CD (safe for createcd)
- if [[ "$is_large_disc" == true ]]; then
- echo "dvd"; return 0
- else
- echo "cd"; return 0
- fi
-}
-
-convert_disc_file() {
- local file="$1"
- local outdir="$2"
-
- # If it's a CUE, validate referenced files first
- if [[ "${file,,}" == *.cue ]]; then
- if ! validate_cue_file "$file"; then
- log ERROR "โ Missing referenced file in CUE: $file"
- return 1
- fi
- fi
-
- local base
- base="$(get_chd_basename "$file")"
- local chd_path="$outdir/$base.chd"
- local tmp_chd="$outdir/$base.chd.tmp"
-
- # If a CHD already exists, verify it and skip if good
- if [[ -f "$chd_path" ]]; then
- log INFO "๐ Verifying existing CHD before conversion: $chd_path"
- if verify_chds "$outdir" "$base.chd"; then
- log INFO "โ
Existing CHD verified, skipping conversion: $chd_path"
- return 0
- else
- failures=$((failures + 1))
- log WARN "โ Existing CHD verification failed, will convert and replace"
- fi
- fi
-
- # Decide CD vs DVD and pick subcommand + icon
- local disc_type
- disc_type="$(detect_disc_type "$file")"
- local hunk_size=2448 #Default
-
- local subcmd icon
- case "$disc_type" in
- ps2|psp|dvd)
- log DEBUG "DVD detected, CHDMAN_HAS_CREATEDVD=$CHDMAN_HAS_CREATEDVD"
- if [[ "$CHDMAN_HAS_CREATEDVD" == true ]]; then
- subcmd="createdvd"
- hunk_size=2048 # DVD-ROM and PS2/PSP discs use standard 2K data sectors
- icon="๐"
- else
- log WARN "โ ๏ธ Detected DVD image but this chdman lacks 'createdvd'. Skipping: $file"
- failures=$((failures + 1))
- return 1
- fi
- ;;
- ps1|dreamcast|segacd|saturn|cd)
- subcmd="createcd"
- hunk_size=2448 # CD-ROMs use 2352-byte raw sectors but chdman createcd expects 2448 to include subchannel data for full disc preservation
- icon="๐ฟ"
- ;;
- *)
- log WARN "โ ๏ธ Unknown disc type detected, defaulting to CD settings: $file"
- subcmd="createcd"
- hunk_size=2448
- icon="๐ฟ"
- ;;
- esac
-
- log INFO "$icon Detected $disc_type image โ using chdman $subcmd"
- log INFO "๐ง Converting: $file -> $tmp_chd"
-
- # Calculate safe threads: ~one thread per 2GB RAM (for CDs) or 4GB RAM (for DVDs)
- local total_ram_mb=$(( $(awk '/MemTotal|SwapTotal/{sum+=$2} END{print sum}' /proc/meminfo) / 1024 ))
- local available_ram=$total_ram_mb
-
- if [[ "$IS_RAM_DISK" == true ]]; then
- local iso_size_mb=$(( $(stat -c%s "$file") / 1048576 ))
- (( available_ram = total_ram_mb - iso_size_mb))
- log DEBUG "๐ RAM disk detected. Adjusting available RAM for chdman to ${available_ram}MB"
- fi
-
- local cpu_cores
- cpu_cores=$(nproc)
- local ram_per_thread=2048 # Default for CDs
-
- if [[ "$subcmd" == "createdvd" ]]; then
- ram_per_thread=4096 # 4GB floor for DVDs (LZMA is a beast here)
- fi
-
- # Calculate threads based on RAM (aiming for ~3GB per thread)
- local threads=$(( available_ram / ram_per_thread ))
- (( threads < 1 )) && threads=1
- (( threads > cpu_cores )) && threads=$cpu_cores
-
- if [[ "$DRY_RUN" == true ]]; then
- log INFO "๐งช (dry-run) Would run: ${CHDMAN_BIN:-chdman} $subcmd -np $threads -i \"$file\" -o \"$tmp_chd\""
- return 0
- fi
-
- if [[ -t 2 && "${PROGRESS_STYLE:-$PROGRESS_STYLE_DEFAULT}" != "none" ]]; then
- if ! PHASE_DEFAULT="Converting" stdbuf -oL -eL "${CHDMAN_BIN:-chdman}" "$subcmd" -np "$threads" -hs "$hunk_size" -i "$file" -o "$tmp_chd" 2>&1 | _chdman_progress_filter; then
- log ERROR "โ chdman $subcmd failed for: $file"
- return 1
- fi
- else
- if ! "${CHDMAN_BIN:-chdman}" "$subcmd" -np "$threads" -hs "$hunk_size" -i "$file" -o "$tmp_chd"; then
- log ERROR "โ chdman $subcmd failed for: $file"
- return 1
- fi
- fi
-
- sync
- sleep 1
- return 0
-}
-
-process_input() {
- local input_file="$1"
- local ext="${input_file##*.}"; ext="${ext,,}"
- local outdir
- outdir="$(dirname "$input_file")"
-
- local archive_entries=()
- local disc_files=()
- local expected_chds=()
- local archive_size_bytes
- archive_size_bytes=$(get_file_size "$input_file")
- local ext_regex
- ext_regex="$(build_ext_regex "${disc_exts[@]}")"
- local temp_dir=""
-
- _cleanup() {
- if [[ -n "$temp_dir" && -d "$temp_dir" ]]; then
- rm -rf -- "$temp_dir"
- log INFO "๐งน Cleaned up temp dir: $temp_dir"
- fi
- }
- # One-shot traps: cleanup on normal return and on error; clear both on return.
- trap '_cleanup; trap - RETURN; trap - ERR' RETURN ERR
-
- if is_in_list "$ext" "${archive_exts[@]}"; then
- archives_processed=$((archives_processed + 1))
- case "$ext" in
- zip) mapfile -t archive_entries < <(unzip -Z1 "$input_file" | grep -Ei "$ext_regex") ;;
- rar) mapfile -t archive_entries < <(unrar lb "$input_file" | grep -Ei "$ext_regex") ;;
- 7z|7zip) mapfile -t archive_entries < <(7z l -ba "$input_file" | grep -Ei "$ext_regex") ;;
- esac
- for entry in "${archive_entries[@]}"; do
- expected_chds+=("$(get_chd_basename "$entry").chd")
- done
- fi
-
- if is_in_list "$ext" "${disc_exts[@]}"; then
- disc_files+=("$input_file")
- expected_chds+=("$(get_chd_basename "$input_file").chd")
- fi
-
- if [[ ${#expected_chds[@]} -eq 0 ]]; then
- log WARN "โญ๏ธ Skipping $input_file - no disc files found (not a supported archive or disc format)"
- return 0
- fi
-
- # If all expected CHDs already exist and verify, remove original and done
- if verify_chds "$outdir" "${expected_chds[@]}"; then
- log INFO "โ
All expected CHDs verified for $input_file"
- if [[ "$KEEP_ORIGINALS" != true ]]; then
- if [[ "$DRY_RUN" == true ]]; then
- log INFO "๐งช (dry-run) Would remove original input file: $input_file"
- else
- log INFO "๐๏ธ Removing original input file: $input_file"
- rm -f -- "$input_file"
- fi
- else
- log INFO "๐ฆ Keeping original input file due to KEEP_ORIGINALS=true"
- fi
- # Per-iteration M3U generation for already-present sets
- if [[ ${#expected_chds[@]} -gt 0 ]]; then
- local chd_base
- chd_base="$(basename "${expected_chds[0]}" .chd)"
- log DEBUG "๐ค Raw base name: $chd_base"
- maybe_generate_m3u_for "$chd_base" "$outdir"
- fi
- return 0
- fi
-
- # Extract archive to temp and discover disc files
- if is_in_list "$ext" "${archive_exts[@]}"; then
- if [[ "$DRY_RUN" == true ]]; then
- # No mktemp in dry-run โ just show intent
- local _dry_temp="(tempdir)"
- log INFO "๐งช (dry-run) Would extract $input_file to $_dry_temp"
- # We also skip scanning extracted files in dry-run (no filesystem changes exist).
- # But keep expected_chds populated from archive listing (already done above).
- else
- temp_dir="$(mktemp -d -p "$TMPDIR" "chdconv_$(basename "$input_file" ".${ext}")_XXXX")"
- log INFO "๐ฆ Extracting $input_file to $temp_dir"
- TEMP_DIRS+=("$temp_dir")
- case "$ext" in
- zip) unzip -qq "$input_file" -d "$temp_dir" ;;
- rar) unrar x -o+ "$input_file" "$temp_dir" >/dev/null ;;
- 7z|7zip) 7z x -y -o"$temp_dir" "$input_file" >/dev/null ;;
- esac
-
- log DEBUG "๐งน Flushing extraction buffers to free up RAM..."
- sync
-
- read -r -a disc_find_expr <<< "$(build_find_expr "${disc_exts[@]}")"
- mapfile -d '' -t disc_files < <(find "$temp_dir" -type f \( "${disc_find_expr[@]}" \) -print0)
-
- if [[ ${#disc_files[@]} -eq 0 && ${#archive_entries[@]} -gt 0 ]]; then
- for entry in "${archive_entries[@]}"; do
- local full_path="$temp_dir/$entry"
- [[ -f "$full_path" ]] && disc_files+=("$full_path")
- done
- fi
- fi
-
- # In dry-run, also show which members would be converted
- if [[ "$DRY_RUN" == true && ${#archive_entries[@]} -gt 0 ]]; then
- for entry in "${archive_entries[@]}"; do
- # Mirror the naming used later: basename without extension + .chd
- log INFO "๐งช (dry-run) Would convert: $entry -> $outdir/$(basename "${entry%.*}").chd"
- done
- fi
- fi
-
- local archive_chd_size=0
- local tmp_chds=()
-
- if [[ "$DRY_RUN" == true ]]; then
- for disc in "${disc_files[@]}"; do
- log INFO "๐งช (dry-run) Would convert: $disc -> $outdir/$(basename "${disc%.*}").chd"
- done
- else
- for disc in "${disc_files[@]}"; do
- if convert_disc_file "$disc" "$outdir"; then
- tmp_chds+=("$outdir/$(basename "${disc%.*}").chd.tmp")
- else
- failures=$((failures + 1))
- fi
- done
- fi
-
- # Verify .tmp CHDs and finalize
- if [[ "$DRY_RUN" != true && ${#tmp_chds[@]} -gt 0 ]]; then
- if verify_chds "$outdir" "${tmp_chds[@]##*/}"; then
- for tmp_chd in "${tmp_chds[@]}"; do
- local final_chd="${tmp_chd%.tmp}"
- mv -f -- "$tmp_chd" "$final_chd"
- log INFO "๐ Replaced old CHD with new verified CHD: $final_chd"
- chds_created=$((chds_created + 1))
- done
-
- if [[ "$KEEP_ORIGINALS" != true ]]; then
- if [[ "$DRY_RUN" == true ]]; then
- log INFO "๐งช (dry-run) Would remove original input file: $input_file"
- else
- log INFO "๐๏ธ Removing original input file: $input_file"
- rm -f -- "$input_file"
- fi
- else
- log INFO "๐ฆ Keeping original input file due to KEEP_ORIGINALS=true"
- fi
-
- for chd in "${expected_chds[@]}"; do
- if [[ -f "$outdir/$chd" ]]; then
- archive_chd_size=$((archive_chd_size + $(get_file_size "$outdir/$chd")))
- fi
- done
- if [[ $archive_size_bytes -gt 0 ]]; then
- local saving=$((archive_size_bytes - archive_chd_size))
- local saving_percent=$((100 * saving / archive_size_bytes))
- log INFO "๐ Space saving for $(basename "$input_file"): $(human_readable "$archive_size_bytes") โ $(human_readable "$archive_chd_size"), saved $(human_readable "$saving") (${saving_percent}%)"
- total_original_size=$((total_original_size + archive_size_bytes))
- total_chd_size=$((total_chd_size + archive_chd_size))
- fi
- else
- for tmp_chd in "${tmp_chds[@]}"; do
- if [[ -f "$tmp_chd" ]]; then
- rm -f "$tmp_chd"
- log INFO "๐๏ธ Removed failed tmp CHD: $tmp_chd"
- fi
- done
- failures=$((failures + 1))
- log WARN "โ ๏ธ CHD verification failed after conversion for $input_file, keeping original"
- fi
- fi
-
- # Per-iteration M3U generation for newly written CHDs
- if [[ ${#expected_chds[@]} -gt 0 ]]; then
- local chd_base
- chd_base="$(basename "${expected_chds[0]}" .chd)"
- log DEBUG "๐ค Raw base name: $chd_base"
- maybe_generate_m3u_for "$chd_base" "$outdir"
- fi
-
- return 0
-}
-
-# Main processing loop
-read -r -a find_expr <<< "$(build_find_expr "${all_exts[@]}")"
-
-if [[ -z "${find_expr[*]:-}" ]]; then
- log ERROR "โ ๏ธ No valid file extensions found for searching, exiting."
- exit 1
-fi
-
-if [[ "$RECURSIVE" == true ]]; then
- mapfile -d '' -t all_inputs < <(
- find "$INPUT_DIR" \( -path '*/.*' -prune \) -o -type f \( "${find_expr[@]}" \) -print0
- )
-else
- mapfile -d '' -t all_inputs < <(
- find "$INPUT_DIR" -maxdepth 1 -type f \( "${find_expr[@]}" \) -print0
- )
-fi
-log INFO "๐ Found ${#all_inputs[@]} inputs"
-
-for input in "${all_inputs[@]}"; do
- log INFO "โถ๏ธ Processing file: $input"
- if ! process_input "$input"; then
- log ERROR "โ ๏ธ Failed to process $input"
- fi
-done
-
-log INFO "๐ Summary:"
-if [[ $total_original_size -gt 0 ]]; then
- total_saved=$((total_original_size - total_chd_size))
- total_percent=$((100 * total_saved / total_original_size))
- log INFO "๐ฆ Total original size: $(human_readable "$total_original_size")"
- log INFO "๐ฟ Total CHD size: $(human_readable "$total_chd_size")"
- log INFO "๐ Total space saved: $(human_readable "$total_saved") (${total_percent}%)"
-fi
-log INFO "๐ฆ Archives processed: $archives_processed"
-log INFO "๐ฟ CHDs created: $chds_created"
-log INFO "โ Failures: $failures"
-log INFO "โฑ๏ธ Elapsed time: $(format_duration $(( $(date +%s) - script_start_time )))"
-log INFO "โ
Done!"
diff --git a/chdtool.sh b/chdtool.sh
index 2253c88..0d441d1 100755
--- a/chdtool.sh
+++ b/chdtool.sh
@@ -6,19 +6,37 @@ script_start_time=$(date +%s)
shopt -s nullglob
shopt -s extglob
-USAGE="Usage: $0 [--keep-originals|-k] [--recursive|-r] [--dry-run|-n] [--file-tee|-F|--no-file-tee|-N] "
+CHDTOOL_VERSION="0.3.0"
+PROGRAM_NAME="$(basename -- "$0")"
+USAGE="Usage: $PROGRAM_NAME [options] [--] "
+
+print_usage() {
+ cat <&2
+ echo "$USAGE" >&2; exit 1
+ fi
+ INPUT_DIR="${1%/}"
+ shift
+ break ;;
-*)
echo "โ Unknown option: $1" >&2
echo "$USAGE" >&2; exit 1 ;;
@@ -49,16 +82,18 @@ done
if [[ -z "$INPUT_DIR" ]]; then
echo "$USAGE" >&2; exit 1
fi
+[[ "$INPUT_DIR" == -* ]] && INPUT_DIR="./$INPUT_DIR"
if [[ ! -d "$INPUT_DIR" ]]; then
echo "โ Input directory does not exist or is not a directory: $INPUT_DIR" >&2
exit 1
fi
# Use a disk-based temp directory to avoid filling up RAM
-TMPDIR="/var/tmp/chdtool"
-mkdir -p "$TMPDIR"
+TMP_ROOT="${TMPDIR:-/var/tmp/chdtool}"
+TMPDIR="$TMP_ROOT/$RUN_ID"
+[[ "$DRY_RUN" == true ]] || mkdir -p "$TMPDIR"
-LOGFILE="logs/chd_conversion_$(date +%Y%m%d_%H%M%S).log"
+LOGFILE="${LOGFILE:-logs/chd_conversion_$(date +%Y%m%d_%H%M%S).log}"
# --- Pluggable logging: console/file/syslog/journald (auto) -------------------
# Control via env vars (no root needed to write to journald/syslog):
@@ -110,7 +145,7 @@ if [[ "$LOG_BACKEND" == journald || "$LOG_BACKEND" == syslog ]]; then
fi
# ensure the logfile directory exists when we might write to it
-if [[ "$LOG_BACKEND" == file || "$LOG_BACKEND" == console ]] || __should_mirror_file; then
+if [[ "$LOG_BACKEND" == file ]] || __should_mirror_file; then
mkdir -p -- "$(dirname -- "$LOGFILE")"
fi
@@ -179,11 +214,19 @@ _emit_log() {
__should_mirror_console && __console_print "$ts" "$lvl" "$msg"
;;
console|*)
- while IFS= read -r line; do
- printf '[%s] %s: %s\n' "$ts" "$lvl" "$line" | tee -a "$LOGFILE"
- done <<< "$msg"
+ if __should_mirror_file; then
+ while IFS= read -r line; do
+ printf '[%s] %s: %s\n' "$ts" "$lvl" "$line" | tee -a "$LOGFILE"
+ done <<< "$msg"
+ else
+ __console_print "$ts" "$lvl" "$msg"
+ fi
;;
esac
+
+ # Optional mirrors may legitimately be disabled. Logging still succeeded
+ # when the selected backend accepted the message.
+ return 0
}
# Public logger. Backwards-compatible: `log "message"` still works.
@@ -200,9 +243,11 @@ log() {
_emit_log "$lvl" "$*"
}
+log DEBUG "๐ Using temp workspace: $TMPDIR"
log INFO "๐ Script started, input dir: $INPUT_DIR"
[[ "$RECURSIVE" == true ]] && log INFO "๐ Recursive mode enabled โ scanning subdirectories"
[[ "$DRY_RUN" == true ]] && log INFO "๐งช Dry-run mode: no files will be written, moved, or deleted"
+[[ "$ALLOW_UNVERIFIED_CUE_AUDIO" == true ]] && log WARN "โ ๏ธ Allowing unverified/lossy CUE audio tracks โ conversion may succeed from non-preservation-grade sources"
is_in_list() {
local needle="$1"; shift
@@ -211,7 +256,7 @@ is_in_list() {
return 1
}
-required_commands=(chdman unzip unrar 7z stat awk)
+required_commands=(chdman unzip unrar 7z stat awk stdbuf)
archive_exts=(zip rar 7z 7zip)
disc_exts=(iso cue gdi ccd)
all_exts=("${archive_exts[@]}" "${disc_exts[@]}")
@@ -264,20 +309,15 @@ fi
# Detect 'createdvd' capability (newer chdman versions)
CHDMAN_HAS_CREATEDVD=false
if command -v chdman >/dev/null 2>&1; then
- set +o pipefail
- log DEBUG "chdman -help output: $(chdman -help 2>&1)"
- if chdman -help 2>&1 | grep -qiE 'createdvd'; then
- log DEBUG "Found 'createdvd' in chdman -help output"
- CHDMAN_HAS_CREATEDVD=true
- else
- log DEBUG "Did NOT find 'createdvd' in chdman -help output"
- CHDMAN_HAS_CREATEDVD=false
- fi
- set -o pipefail
- log DEBUG "โน๏ธ chdman createdvd support: $CHDMAN_HAS_CREATEDVD"
+ if chdman help createdvd >/dev/null 2>&1; then
+ CHDMAN_HAS_CREATEDVD=true
+ log DEBUG "โน๏ธ chdman createdvd support: true (via 'chdman help createdvd')"
+ else
+ CHDMAN_HAS_CREATEDVD=false
+ log DEBUG "โน๏ธ chdman createdvd support: false (no 'createdvd' help topic)"
+ fi
else
- # In dry-run without chdman, just log at DEBUG and keep default false
- [[ "$DRY_RUN" == true ]] && log DEBUG "โน๏ธ chdman not present (dry-run); assuming no createdvd"
+ [[ "$DRY_RUN" == true ]] && log DEBUG "โน๏ธ chdman not present (dry-run); assuming no createdvd"
fi
total_original_size=0
@@ -329,12 +369,177 @@ check_temp_storage() {
log WARN "โ ๏ธ $tmp_dir is a RAM disk (tmpfs). Extracted ISOs will consume physical RAM!"
log WARN "๐ก Available space in RAM disk: $tmp_limit"
- # Force thread reduction if we are in a RAM disk
- IS_RAM_DISK=true
fi
}
-check_temp_storage "$TMPDIR"
+archive_entry_to_chd_name() {
+ # $1 = path inside archive, e.g. "CD1/Game.cue"
+ local entry="$1"
+ local stem="${entry%.*}"
+ # preserve subdir info to avoid collisions, but keep it filename-safe
+ stem="${stem//\// - }"
+ stem="$(sanitize_filename "$stem")"
+ printf '%s.chd' "$stem"
+}
+
+select_preferred_disc_candidates() {
+ local candidates=("$@")
+ local -a cues=() gdis=() ccds=() isos=()
+ local candidate ext
+
+ for candidate in "${candidates[@]}"; do
+ ext="${candidate##*.}"
+ ext="${ext,,}"
+ case "$ext" in
+ cue) cues+=("$candidate") ;;
+ gdi) gdis+=("$candidate") ;;
+ ccd) ccds+=("$candidate") ;;
+ iso) isos+=("$candidate") ;;
+ esac
+ done
+
+ if (( ${#cues[@]} > 0 )); then
+ printf '%s\n' "${cues[@]}"
+ elif (( ${#gdis[@]} > 0 )); then
+ printf '%s\n' "${gdis[@]}"
+ elif (( ${#ccds[@]} > 0 )); then
+ printf '%s\n' "${ccds[@]}"
+ else
+ printf '%s\n' "${isos[@]}"
+ fi
+}
+
+is_safe_relative_path() {
+ local path="${1//\\//}"
+ [[ -n "$path" && "$path" != /* && ! "$path" =~ ^[[:alpha:]]: && "$path" != //* ]] || return 1
+ local part
+ IFS='/' read -r -a _path_parts <<< "$path"
+ for part in "${_path_parts[@]}"; do
+ [[ "$part" != ".." ]] || return 1
+ done
+}
+
+# Resolve each path component independently. This preserves safe subdirectories,
+# permits case-insensitive media layouts, and rejects ambiguous case-fold matches.
+resolve_descriptor_reference() {
+ local base_dir="$1" ref="${2//\\//}" current="$1" part match
+ is_safe_relative_path "$ref" || return 1
+ IFS='/' read -r -a _ref_parts <<< "$ref"
+ for part in "${_ref_parts[@]}"; do
+ [[ -n "$part" && "$part" != "." ]] || continue
+ if [[ -e "$current/$part" ]]; then
+ current="$current/$part"
+ continue
+ fi
+ local -a matches=()
+ while IFS= read -r -d '' match; do matches+=("$match"); done < <(
+ find "$current" -mindepth 1 -maxdepth 1 -iname "$part" -print0 2>/dev/null
+ )
+ (( ${#matches[@]} == 1 )) || return 1
+ current="${matches[0]}"
+ done
+ [[ -f "$current" ]] || return 1
+ printf '%s\n' "$current"
+}
+
+DESCRIPTOR_SOURCE_SET=()
+
+validate_descriptor_file() {
+ local descriptor="$1" ext="${1##*.}" base_dir stem line ref resolved
+ ext="${ext,,}"
+ base_dir="$(dirname "$descriptor")"
+ stem="${descriptor%.*}"
+ DESCRIPTOR_SOURCE_SET=("$descriptor")
+
+ case "$ext" in
+ cue)
+ local unsupported_audio=0
+ while IFS= read -r line; do
+ if [[ "$line" =~ ^[[:space:]]*FILE[[:space:]]+\"([^\"]+)\" ]]; then
+ ref="${BASH_REMATCH[1]}"
+ if ! is_safe_relative_path "$ref"; then
+ log ERROR "โ Unsafe path in CUE: $ref (required by $descriptor)"
+ return 1
+ fi
+ if ! resolved="$(resolve_descriptor_reference "$base_dir" "$ref")"; then
+ log ERROR "โ Missing or ambiguous referenced file in CUE: $ref (required by $descriptor)"
+ return 1
+ fi
+ DESCRIPTOR_SOURCE_SET+=("$resolved")
+ case "${ref,,}" in
+ *.mp3|*.ogg|*.opus|*.m4a|*.flac)
+ if [[ "$ALLOW_UNVERIFIED_CUE_AUDIO" != true ]]; then
+ log ERROR "โ CUE references unsupported audio format: $ref"
+ unsupported_audio=1
+ fi ;;
+ esac
+ fi
+ done < "$descriptor"
+ (( unsupported_audio == 0 )) || return 1
+ ;;
+ gdi)
+ local track_count=0
+ while IFS= read -r line; do
+ [[ "$line" =~ ^[[:space:]]*[0-9]+[[:space:]] ]] || continue
+ # GDI track filenames are the fifth field; quoted names may contain spaces.
+ ref="$(awk 'match($0,/^([^[:space:]]+[[:space:]]+){4}("[^"]+"|[^[:space:]]+)/){v=substr($0,RSTART,RLENGTH); sub(/^([^[:space:]]+[[:space:]]+){4}/,"",v); gsub(/^"|"$/,"",v); print v}' <<< "$line")"
+ [[ -n "$ref" ]] || { log ERROR "โ Invalid GDI track entry: $line"; return 1; }
+ is_safe_relative_path "$ref" || { log ERROR "โ Unsafe path in GDI: $ref"; return 1; }
+ resolved="$(resolve_descriptor_reference "$base_dir" "$ref")" || { log ERROR "โ Missing or ambiguous GDI track: $ref"; return 1; }
+ DESCRIPTOR_SOURCE_SET+=("$resolved"); track_count=$((track_count + 1))
+ done < "$descriptor"
+ (( track_count > 0 )) || { log ERROR "โ GDI contains no valid track entries: $descriptor"; return 1; }
+ ;;
+ ccd)
+ local companion
+ for companion in img sub; do
+ ref="$(basename "$stem").$companion"
+ resolved="$(resolve_descriptor_reference "$base_dir" "$ref")" || { log ERROR "โ Missing CCD companion file: $ref"; return 1; }
+ DESCRIPTOR_SOURCE_SET+=("$resolved")
+ done
+ ;;
+ *) return 0 ;;
+ esac
+ log DEBUG "โ
Descriptor source-set validation passed: $descriptor"
+}
+
+validate_archive_member_paths() {
+ local member normalized part
+ for member in "$@"; do
+ normalized="${member//\\//}"
+ [[ "$normalized" == */ ]] && normalized="${normalized%/}"
+ [[ -z "$normalized" ]] && continue
+ if ! is_safe_relative_path "$normalized"; then
+ log ERROR "โ Unsafe archive member path: $member"
+ return 1
+ fi
+ done
+}
+
+validate_archive_output_names() {
+ declare -A seen=()
+ local entry output key
+ for entry in "$@"; do
+ output="$(archive_entry_to_chd_name "$entry")"
+ key="${output,,}"
+ if [[ -n "${seen[$key]:-}" ]]; then
+ log ERROR "โ Archive entries collide after output-name sanitisation: ${seen[$key]} and $entry -> $output"
+ return 1
+ fi
+ seen["$key"]="$entry"
+ done
+}
+
+validate_extracted_tree() {
+ local root="$1" link target
+ while IFS= read -r -d '' link; do
+ target="$(readlink -- "$link" 2>/dev/null || true)"
+ log ERROR "โ Unsafe archive link rejected: ${link#"$root/"} -> $target"
+ return 1
+ done < <(find "$root" -type l -print0)
+}
+
+[[ "$DRY_RUN" == true ]] || check_temp_storage "$TMPDIR"
# ---------- chdman progress handling ----------
# Config: PROGRESS_STYLE=auto|bar|line|none ; default: auto (TTY -> bar, non-TTY -> none)
@@ -409,14 +614,16 @@ _now_ms() {
# Use with: PHASE_DEFAULT="Converting" chdman createcd โฆ | _chdman_progress_filter
# or: PHASE_DEFAULT="Verifying" chdman verify โฆ | _chdman_progress_filter
_chdman_progress_filter() {
- # Always re-enable autowrap on exit/interrupt; return non-zero on INT/TERM
+ # Invoked indirectly by the traps below.
+ # shellcheck disable=SC2317
_restore_wrap() { _term_print "\033[?7h"; }
trap '_restore_wrap; return 130' INT TERM
trap _restore_wrap EXIT
local last_draw=0 phase="${PHASE_DEFAULT:-Compressing}" ratio="" progress_active=0 ms
- while IFS= read -d $'\r' -r line || [[ -n "$line" ]]; do
+ # Read from a process-substitution so the while-loop stays in THIS shell
+ while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ ([0-9]+([.][0-9]+)?)%[[:space:]]*complete ]]; then
local pct="${BASH_REMATCH[1]}"
[[ "$line" =~ ^([A-Za-z]+), ]] && phase="${BASH_REMATCH[1]}"
@@ -431,16 +638,14 @@ _chdman_progress_filter() {
continue
fi
- # Ignore obvious chopped progress fragments
if [[ "$line" =~ ^[[:space:]]*([A-Za-z]+,)?[[:space:]]*$ ]] || \
- [[ "$line" =~ ^[[:space:]]*[0-9]+([.][0-9]+)?[[:space:]]*$ ]]; then
+ [[ "$line" =~ ^[[:space:]]*[0-9]+([.][0-9]+)?[[:space:]]*$ ]]; then
continue
fi
log "$CHDMAN_MSG_LEVEL" "$line"
- done
+ done < <(tr $'\r' $'\n' <&0) # CR โ NL normalisation happens here
- # At EOF: end the progress line neatly with a single newline
(( progress_active )) && _term_print "\r\033[2K\n"
}
@@ -502,14 +707,13 @@ parse_disc_info() {
# Pattern set 1: Disc/CD/Disk/GD(-ROM)? with optional separator or none:
# e.g., "Title Disc2", "Title (CD-2)", "Title [Disk02]", "Title GD-ROM 3", "Title Disc 01"
# ERE (bash) has no (?: ). Keep groups simple and predictable.
- local re_core='([Dd]isc|[Cc][Dd]|[Dd]isk|[Gg][Dd]|[Gg][Dd]-[Rr][Oo][Mm])'
+ local re_disc_labels='([Dd]isc|[Cc][Dd]|[Dd]isk|[Gg][Dd]|[Gg][Dd]-[Rr][Oo][Mm])'
local re_num='([0-9]{1,3})'
local re_sep='[[:space:]]*[-_.]?[[:space:]]*'
# For the compact/union pattern, keep it a single capturing group:
- local re_label_union='([Dd]isc|[Cc][Dd]|[Dd]isk|[Gg][Dd]|[Gg][Dd]-[Rr][Oo][Mm]|[Vv]ol|[Vv]olume|[Pp]art|[Pp]t\.?)'
-
+ local re_label_union="(${re_disc_labels:1:-1}|[Vv]ol|[Vv]olume|[Pp]art|[Pp]t\\.?)"
- if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*\(?$re_core$re_sep$re_num\)?([[:space:]]*.*)?$ ]]; then
+ if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*\(?$re_disc_labels$re_sep$re_num\)?([[:space:]]*.*)?$ ]]; then
local base="${BASH_REMATCH[1]}"
local num="${BASH_REMATCH[3]}" # (1=label,2=sep? depends on grouping; ensure index)
# Because of our grouping above, indexes are:
@@ -536,7 +740,7 @@ parse_disc_info() {
fi
# Pattern set 4: "1 of 2" / "1/2"
- if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*\(?([0-9]+)[[:space:]]*(?:of|/)[[:space:]]*[0-9]+\)?([[:space:]]*.*)?$ ]]; then
+ if [[ "$name_norm" =~ ^(.*?)[[:space:]._-]*\(?([0-9]+)[[:space:]]*([Oo][Ff]|/)[[:space:]]*[0-9]+\)?([[:space:]]*.*)?$ ]]; then
local base="${BASH_REMATCH[1]}"
local num="${BASH_REMATCH[2]}"
base="$(tidy_base "$base")"
@@ -652,6 +856,7 @@ generate_m3u_for_base() {
log INFO "๐งช (dry-run) Would (re)write M3U: $m3u_path with ${#members[@]} lines"
else
local tmp_m3u="$m3u_path.tmp"
+ track_temp_file "$tmp_m3u"
: > "$tmp_m3u"
for f in "${members[@]}"; do
printf '%s\n' "$(basename "$f")" >> "$tmp_m3u"
@@ -695,39 +900,67 @@ maybe_generate_m3u_for() {
# --- Global interrupt + cleanup handling --------------------------------------
# Track temp dirs created during processing so we can clean them on SIGINT/TERM
declare -a TEMP_DIRS=()
+declare -a TEMP_FILES=()
+
+track_temp_dir() { TEMP_DIRS+=("$1"); }
+track_temp_file() { TEMP_FILES+=("$1"); }
+
+untrack_temp_file() {
+ local f="$1" i
+ for i in "${!TEMP_FILES[@]}"; do
+ [[ "${TEMP_FILES[$i]}" == "$f" ]] && unset 'TEMP_FILES[$i]'
+ done
+}
+
+cleanup_temp_file_now() {
+ local f="$1"
+ if [[ -n "$f" && -f "$f" ]]; then
+ rm -f -- "$f"
+ log INFO "๐๏ธ Removed temp file: $f"
+ fi
+ untrack_temp_file "$f"
+}
+
+cleanup_temp_dir_now() {
+ local d="$1"
+ [[ -n "$d" && -d "$d" ]] || return 0
+ rm -rf -- "$d"
+ log INFO "๐งน Cleaned up temp dir: $d"
+ # remove it from TEMP_DIRS so cleanup_all won't log it again
+ local i
+ for i in "${!TEMP_DIRS[@]}"; do
+ [[ "${TEMP_DIRS[$i]}" == "$d" ]] && unset 'TEMP_DIRS[$i]'
+ done
+}
+# Invoked indirectly through _on_interrupt's signal trap.
+# shellcheck disable=SC2317
_restore_wrap_global() {
# Make sure terminal autowrap is re-enabled and the progress line cleared
# (safe to emit even if no progress was showing)
_term_print "\r\033[2K\033[?7h\n"
}
+# Invoked indirectly by the EXIT trap and the signal handler.
+# shellcheck disable=SC2317
cleanup_all() {
- # Remove any temp dirs that might still exist
- if ((${#TEMP_DIRS[@]})); then
- for d in "${TEMP_DIRS[@]}"; do
- if [[ -n "$d" && -d "$d" ]]; then
+ # Temp dirs
+ for d in "${TEMP_DIRS[@]:-}"; do
+ [[ -n "$d" && -d "$d" ]] || continue
rm -rf -- "$d"
log INFO "๐งน Cleaned up temp dir: $d"
- fi
done
- fi
- # Remove our known temp files in the working directory tree
- if [[ -n "$INPUT_DIR" && -d "$INPUT_DIR" ]]; then
- while IFS= read -r -d '' f; do
- rm -f -- "$f"
- log INFO "๐๏ธ Removed leftover tmp file: $f"
- done < <(find "$INPUT_DIR" -type f \( -name '*.chd.tmp' -o -name '*.m3u.tmp' \) -print0)
- fi
-
- # Remove verify scratch files in the new temp directory
- while IFS= read -r -d '' f; do
- rm -f -- "$f"
- log INFO "๐๏ธ Removed verify scratch: $f"
- done < <(find "$TMPDIR" -maxdepth 1 -type f -name 'chdverify_*' -print0 2>/dev/null || true)
+ # Temp files
+ for f in "${TEMP_FILES[@]:-}"; do
+ [[ -n "$f" && -f "$f" ]] || continue
+ rm -f -- "$f"
+ log INFO "๐๏ธ Removed temp file: $f"
+ done
}
+# Invoked indirectly by the INT and TERM traps.
+# shellcheck disable=SC2317
_on_interrupt() {
# One place to handle Ctrl-C/TERM: restore terminal, clean, then exit(130)
_restore_wrap_global
@@ -748,13 +981,15 @@ verify_chds() {
if [[ "$DRY_RUN" == true ]]; then
for chd in "${chds[@]}"; do
- log INFO "๐งช (dry-run) Would verify: $outdir/$chd"
+ [[ -n "$outdir" ]] && chd="$outdir/$chd"
+ log INFO "๐งช (dry-run) Would verify: $chd"
done
return 0
fi
for chd in "${chds[@]}"; do
- local chd_path="$outdir/$chd"
+ local chd_path="$chd"
+ [[ -n "$outdir" ]] && chd_path="$outdir/$chd"
if [[ ! -f "$chd_path" ]]; then
all_verified=false
break
@@ -763,11 +998,12 @@ verify_chds() {
local verify_exit_code=0
local tmpout
tmpout="$(mktemp -p "$TMPDIR" chdverify_XXXXXX)"
+ track_temp_file "$tmpout"
log INFO "๐ Verifying: $chd_path"
if [[ -t 2 && "${PROGRESS_STYLE:-$PROGRESS_STYLE_DEFAULT}" != "none" ]]; then
# TTY: show single-line progress, capture full output to tmp for analysis
- if PHASE_DEFAULT="Verifying" "${CHDMAN_BIN:-chdman}" verify -i "$chd_path" 2>&1 \
+ if PHASE_DEFAULT="Verifying" stdbuf -oL -eL "${CHDMAN_BIN:-chdman}" verify -i "$chd_path" 2>&1 \
| tee "$tmpout" \
| _chdman_progress_filter
then
@@ -799,7 +1035,7 @@ verify_chds() {
: > "$tmpout"
log INFO "๐ Verifying: $chd_path"
if [[ -t 2 && "${PROGRESS_STYLE:-$PROGRESS_STYLE_DEFAULT}" != "none" ]]; then
- if PHASE_DEFAULT="Verifying" "${CHDMAN_BIN:-chdman}" verify -i "$chd_path" 2>&1 \
+ if PHASE_DEFAULT="Verifying" stdbuf -oL -eL "${CHDMAN_BIN:-chdman}" verify -i "$chd_path" 2>&1 \
| tee "$tmpout" \
| _chdman_progress_filter
then
@@ -819,7 +1055,6 @@ verify_chds() {
# Still failed: log concise reasons and clean up
failure_reasons="$(grep -iE 'error|fail|invalid|corrupt' "$tmpout" || true)"
[[ -n "$failure_reasons" ]] && log DEBUG " Failure details: $failure_reasons"
- failures=$((failures + 1))
log ERROR "โ Verification failed on retry for: $chd_path โ deleting"
rm -f -- "$chd_path"
rm -f -- "$tmpout"
@@ -834,70 +1069,81 @@ verify_chds() {
$all_verified && return 0 || return 1
}
-validate_cue_file() {
- local cue_file="$1"
- local cuedir
- cuedir="$(dirname "$cue_file")"
- local cue_basename
- cue_basename="$(basename "$cue_file")"
- local missing=0
-
- declare -A file_map
- while IFS= read -r -d '' f; do
- file_map["${f,,}"]="$f"
- done < <(find "$cuedir" -maxdepth 1 -type f -printf '%f\0')
-
- while IFS= read -r line; do
- if [[ "$line" =~ ^[[:space:]]*FILE[[:space:]]+\"([^\"]+)\" ]]; then
- local ref="${BASH_REMATCH[1]}"
- local ref_norm="${ref//\\//}"
- local ref_basename; ref_basename="$(basename "$ref_norm")"
- local ref_lower="${ref_basename,,}"
-
- [[ "$ref_lower" == "${cue_basename,,}" ]] && continue
-
- if [[ "$ref_lower" == *.mp3 || "$ref_lower" == *.wav ]]; then
- log WARN "โ ๏ธ CUE file references unsupported audio format: $ref_basename"
- fi
- if [[ "$ref_norm" == /* || "$ref_norm" == *".."* ]]; then
- log WARN "โ ๏ธ Skipping unsafe external path in CUE: $ref_basename"
- continue
- fi
- if [[ -z "${file_map["$ref_lower"]:-}" ]]; then
- log ERROR "โ Missing referenced file in CUE: $ref_basename (required by $cue_file)"
- missing=1
- fi
- fi
- done < "$cue_file"
-
- return $missing
-}
-
detect_disc_type() {
+ log DEBUG "DEBUG: Starting detection for: $1"
local img="$1"
local ext
ext="${img##*.}"; ext="${ext,,}"
local sz
sz=$(get_file_size "$img" 2>/dev/null || echo 0)
-
+ # --- DEBUGGING ---
+ log DEBUG "sz value for $(basename "$img"): [${sz}]"
+ # -----------------
+
#1. Size-based "hard limits"
# If it's > 1GB, it's a DVD/PS2 regardless of what the header says (some PS2 games have weird headers that look like CDs)
local is_large_disc=false
(( sz >= 1000000000 )) && is_large_disc=true
+ # --- DEBUGGING ---
+ log DEBUG "is_large_disc for $(basename "$img"): $is_large_disc (size: $sz bytes)"
+ # -----------------
+
+ # Sniff the header before checking extensions
+ local sniff_target="$img"
+ if [[ "$ext" == "cue" ]]; then
+ local cue_dir
+ cue_dir="$(dirname "$img")"
+
+ # Prefer a likely DATA file (BIN/ISO/IMG/MDF) over audio tracks.
+ local raw_ref=""
+ raw_ref="$(
+ awk -F'"' '
+ BEGIN { IGNORECASE=1 }
+ /^[[:space:]]*FILE[[:space:]]+"/ {
+ ref=$2
+ low=tolower(ref)
+ gsub(/\\/, "/", low)
+ # pick first "data-ish" file
+ if (low ~ /\.(bin|iso|img|mdf)$/) { print ref; selected=1; exit }
+ # otherwise remember first FILE as fallback
+ if (first == "") first = ref
+ }
+ END { if (!selected && first != "") print first }
+ ' "$img"
+ )"
+
+ # Normalize Windows path separators
+ raw_ref="${raw_ref//\\//}"
+
+ local sniff_candidate=""
+ sniff_candidate="$(resolve_descriptor_reference "$cue_dir" "$raw_ref" 2>/dev/null || true)"
+
+ log DEBUG "DEBUG: CUE selected sniff candidate: [$raw_ref]"
+ log DEBUG "DEBUG: Full resolved sniff path: [$sniff_candidate]"
+
+ if [[ -n "$raw_ref" && -f "$sniff_candidate" ]]; then
+ log DEBUG "DEBUG: Found referenced file. Switching sniff_target."
+ sniff_target="$sniff_candidate"
+ else
+ log DEBUG "DEBUG: FAILED to resolve referenced file from CUE."
+ log DEBUG "DEBUG: Directory contents of $cue_dir: $(ls -m "$cue_dir")"
+ fi
+ fi
- #2. Immediate CD extensions - CUE/CCD/GDI are CD-type by definition
- case "$ext" in
- cue|ccd|gdi) echo "cd"; return 0 ;;
- esac
-
- #3. Console Fingerprinting
+ #2. Console Fingerprinting
# Reading the first 64KB covers Volume Descriptors and Boot Headers
local header
- header=$(head -c 65535 "$img" 2>/dev/null | tr -d '\0')
+ header="$(head -c 65535 -- "$sniff_target" 2>/dev/null | tr -d '\0' || true)"
+
+ # Check for PS2 specifically in debug
+ if [[ "$header" == *"PLAYSTATION 2"* ]]; then
+ log DEBUG "DEBUG: 'PLAYSTATION 2' string found in header of $(basename "$sniff_target")"
+ fi
+ # ------------------------------------------
case "$header" in
*"PLAYSTATION 2"*|*"NTSC-U/C PS2 DVD"*) echo "ps2"; return 0 ;;
- *"PLAYSTATION "*|*"NTSC-U/C PS1 CD"*)
+ *"PLAYSTATION "*|*"NTSC-U/C PS1 CD"*)
# If it says PS1 but it's >1GB, it's a mislabeled PS2 DVD
if [[ "$is_large_disc" == true ]]; then
log WARN "โ ๏ธ Header indicates PS1 but size suggests PS2 DVD. Treating as PS2: $img"
@@ -912,6 +1158,11 @@ detect_disc_type() {
*"PSP GAME"*|*"UMD VIDEO"*) echo "psp"; return 0 ;;
esac
+ #3. Immediate CD extensions - CUE/CCD/GDI are CD-type by definition
+ case "$ext" in
+ cue|ccd|gdi) echo "cd"; return 0 ;;
+ esac
+
#4. UDF/ISO logic fallback
if [[ "$ext" == "iso" ]]; then
if command -v file >/dev/null 2>&1; then
@@ -926,35 +1177,37 @@ detect_disc_type() {
fi
# Size heuristic: โฅ ~1 GB โ likely DVD; otherwise CD
- local sz
- sz=$(get_file_size "$img")
- (( sz >= 1000000000 )) && { echo "dvd"; return 0; }
+ [[ "$is_large_disc" == true ]] && { echo "dvd"; return 0; }
fi
- # Unknown extension โ default to CD (safe for createcd)
- if [[ "$is_large_disc" == true ]]; then
- echo "dvd"; return 0
- else
- echo "cd"; return 0
- fi
+ #5 Final fallback: Unknown extension โ default to CD (safe for createcd)
+ [[ "$is_large_disc" == true ]] && echo "dvd" || echo "cd"
}
convert_disc_file() {
local file="$1"
local outdir="$2"
+ local base_override="${3:-}"
+ local result_var="${4:-}"
- # If it's a CUE, validate referenced files first
- if [[ "${file,,}" == *.cue ]]; then
- if ! validate_cue_file "$file"; then
- log ERROR "โ Missing referenced file in CUE: $file"
+ [[ -n "$result_var" ]] && printf -v "$result_var" '%s' ""
+
+ local file_ext="${file##*.}"; file_ext="${file_ext,,}"
+ if [[ "$file_ext" == "cue" || "$file_ext" == "gdi" || "$file_ext" == "ccd" ]]; then
+ if ! validate_descriptor_file "$file"; then
+ log ERROR "โ Descriptor validation failed: $file"
return 1
fi
fi
local base
- base="$(get_chd_basename "$file")"
+ if [[ -n "$base_override" ]]; then
+ base="$base_override"
+ else
+ base="$(get_chd_basename "$file")"
+ fi
+
local chd_path="$outdir/$base.chd"
- local tmp_chd="$outdir/$base.chd.tmp"
# If a CHD already exists, verify it and skip if good
if [[ -f "$chd_path" ]]; then
@@ -963,7 +1216,6 @@ convert_disc_file() {
log INFO "โ
Existing CHD verified, skipping conversion: $chd_path"
return 0
else
- failures=$((failures + 1))
log WARN "โ Existing CHD verification failed, will convert and replace"
fi
fi
@@ -971,90 +1223,134 @@ convert_disc_file() {
# Decide CD vs DVD and pick subcommand + icon
local disc_type
disc_type="$(detect_disc_type "$file")"
- local hunk_size=2448 #Default
-
local subcmd icon
case "$disc_type" in
- ps2|psp|dvd)
+ ps2)
+ log DEBUG "PS2 image detected, checking size to determine if DVD structure is likely"
+ local sz
+ sz=$(get_file_size "$file")
+ if (( sz >= 1000000000 )); then
+ log DEBUG "Large PS2 image suggests DVD structure, CHDMAN_HAS_CREATEDVD=$CHDMAN_HAS_CREATEDVD"
+ if [[ "$CHDMAN_HAS_CREATEDVD" == true ]]; then
+ log DEBUG "Using createdvd for large PS2 image"
+ subcmd="createdvd"
+ icon="๐"
+ else
+ log WARN "โ ๏ธ PS2 DVD detected but chdman lacks 'createdvd'. Skipping $file."
+ return 1
+ fi
+ else
+ log DEBUG "Smaller PS2 image suggests CD structure, using createcd"
+ subcmd="createcd"
+ icon="๐ฟ"
+ fi
+ ;;
+ psp|dvd)
log DEBUG "DVD detected, CHDMAN_HAS_CREATEDVD=$CHDMAN_HAS_CREATEDVD"
if [[ "$CHDMAN_HAS_CREATEDVD" == true ]]; then
subcmd="createdvd"
- hunk_size=2048 # DVD-ROM and PS2/PSP discs use standard 2K data sectors
icon="๐"
else
log WARN "โ ๏ธ Detected DVD image but this chdman lacks 'createdvd'. Skipping: $file"
- failures=$((failures + 1))
return 1
fi
;;
ps1|dreamcast|segacd|saturn|cd)
+ log DEBUG "CD-type image detected, using createcd"
subcmd="createcd"
- hunk_size=2448 # CD-ROMs use 2352-byte raw sectors but chdman createcd expects 2448 to include subchannel data for full disc preservation
icon="๐ฟ"
;;
*)
log WARN "โ ๏ธ Unknown disc type detected, defaulting to CD settings: $file"
subcmd="createcd"
- hunk_size=2448
icon="๐ฟ"
;;
esac
log INFO "$icon Detected $disc_type image โ using chdman $subcmd"
- log INFO "๐ง Converting: $file -> $tmp_chd"
-
- # Calculate safe threads: ~one thread per 2GB RAM (for CDs) or 4GB RAM (for DVDs)
- local total_ram_mb=$(( $(awk '/MemTotal|SwapTotal/{sum+=$2} END{print sum}' /proc/meminfo) / 1024 ))
- local available_ram=$total_ram_mb
-
- if [[ "$IS_RAM_DISK" == true ]]; then
- local iso_size_mb=$(( $(stat -c%s "$file") / 1048576 ))
- (( available_ram = total_ram_mb - iso_size_mb))
- log DEBUG "๐ RAM disk detected. Adjusting available RAM for chdman to ${available_ram}MB"
- fi
+ # Base concurrency on currently available physical memory. Swap is not
+ # interchangeable with RAM for compression and can cause severe thrashing.
+ local available_ram
+ available_ram=$(( $(awk '/^MemAvailable:/{print $2; found=1} END{if(!found) print 0}' /proc/meminfo 2>/dev/null) / 1024 ))
+ (( available_ram < 1 )) && available_ram=1024
local cpu_cores
- cpu_cores=$(nproc)
+ cpu_cores=$(nproc 2>/dev/null || echo 1)
+ (( cpu_cores < 1 )) && cpu_cores=1
local ram_per_thread=2048 # Default for CDs
if [[ "$subcmd" == "createdvd" ]]; then
ram_per_thread=4096 # 4GB floor for DVDs (LZMA is a beast here)
fi
- # Calculate threads based on RAM (aiming for ~3GB per thread)
+ # Calculate threads from available RAM, then apply the explicit override.
local threads=$(( available_ram / ram_per_thread ))
(( threads < 1 )) && threads=1
(( threads > cpu_cores )) && threads=$cpu_cores
+ if [[ -n "${CHDMAN_THREADS:-}" ]]; then
+ [[ "$CHDMAN_THREADS" =~ ^[1-9][0-9]*$ ]] || { log ERROR "โ CHDMAN_THREADS must be a positive integer"; return 1; }
+ threads="$CHDMAN_THREADS"
+ (( threads > cpu_cores )) && threads=$cpu_cores
+ fi
+
+ local -a chdman_args=("$subcmd" -np "$threads")
+ if [[ -n "${CHDMAN_HUNK_SIZE:-}" ]]; then
+ [[ "$CHDMAN_HUNK_SIZE" =~ ^[1-9][0-9]*$ ]] || { log ERROR "โ CHDMAN_HUNK_SIZE must be a positive integer"; return 1; }
+ chdman_args+=(-hs "$CHDMAN_HUNK_SIZE")
+ fi
+ chdman_args+=(-i "$file")
if [[ "$DRY_RUN" == true ]]; then
- log INFO "๐งช (dry-run) Would run: ${CHDMAN_BIN:-chdman} $subcmd -np $threads -i \"$file\" -o \"$tmp_chd\""
+ local command_preview
+ printf -v command_preview '%q ' "${CHDMAN_BIN:-chdman}" "${chdman_args[@]}" -o ""
+ log INFO "๐งช (dry-run) Would run: ${command_preview% }"
return 0
fi
+ # Reserve a unique location in the destination filesystem. Keeping the
+ # reservation directory until finalisation prevents concurrent runs from
+ # ever allocating or writing the same temporary path.
+ local tmp_dir tmp_chd
+ tmp_dir="$(mktemp -d -p "$outdir" ".${base}.chd.tmp.${RUN_ID}.XXXXXX")" || {
+ log ERROR "โ Could not allocate temporary CHD path for: $chd_path"
+ return 1
+ }
+ track_temp_dir "$tmp_dir"
+ tmp_chd="$tmp_dir/$base.chd"
+ track_temp_file "$tmp_chd"
+ [[ -n "$result_var" ]] && printf -v "$result_var" '%s' "$tmp_chd"
+ log INFO "๐ง Converting: $file -> $tmp_chd"
+ local -a command=("${CHDMAN_BIN:-chdman}" "${chdman_args[@]}" -o "$tmp_chd")
+
if [[ -t 2 && "${PROGRESS_STYLE:-$PROGRESS_STYLE_DEFAULT}" != "none" ]]; then
- if ! PHASE_DEFAULT="Converting" stdbuf -oL -eL "${CHDMAN_BIN:-chdman}" "$subcmd" -np "$threads" -hs "$hunk_size" -i "$file" -o "$tmp_chd" 2>&1 | _chdman_progress_filter; then
+ if ! PHASE_DEFAULT="Converting" stdbuf -oL -eL "${command[@]}" 2>&1 | _chdman_progress_filter; then
log ERROR "โ chdman $subcmd failed for: $file"
+ cleanup_temp_file_now "$tmp_chd"
+ cleanup_temp_dir_now "$tmp_dir"
return 1
fi
else
- if ! "${CHDMAN_BIN:-chdman}" "$subcmd" -np "$threads" -hs "$hunk_size" -i "$file" -o "$tmp_chd"; then
+ if ! "${command[@]}"; then
log ERROR "โ chdman $subcmd failed for: $file"
+ cleanup_temp_file_now "$tmp_chd"
+ cleanup_temp_dir_now "$tmp_dir"
return 1
fi
fi
- sync
- sleep 1
return 0
}
process_input() {
+ local input_failed=false
local input_file="$1"
local ext="${input_file##*.}"; ext="${ext,,}"
local outdir
outdir="$(dirname "$input_file")"
-
local archive_entries=()
+ local archive_all_entries=()
+ local archive_listing=""
+ local archive_listing_exit=0
local disc_files=()
local expected_chds=()
local archive_size_bytes
@@ -1062,26 +1358,99 @@ process_input() {
local ext_regex
ext_regex="$(build_ext_regex "${disc_exts[@]}")"
local temp_dir=""
+ declare -A output_states=()
+ declare -A output_temps=()
+ local direct_source_set=()
+
+ _all_outputs_complete() {
+ local expected state
+ for expected in "${expected_chds[@]}"; do
+ state="${output_states[$expected]:-missing}"
+ [[ "$state" == "already_verified" || "$state" == "finalised" ]] || return 1
+ done
+ return 0
+ }
- _cleanup() {
- if [[ -n "$temp_dir" && -d "$temp_dir" ]]; then
- rm -rf -- "$temp_dir"
- log INFO "๐งน Cleaned up temp dir: $temp_dir"
- fi
+ _return_failed_input() {
+ failures=$((failures + 1))
+ return 1
+ }
+
+ _remove_input_if_allowed() {
+ if [[ "$KEEP_ORIGINALS" == true ]]; then
+ log INFO "๐ฆ Keeping original input file due to KEEP_ORIGINALS=true"
+ return 0
+ fi
+
+ if [[ "$DRY_RUN" == true ]]; then
+ log INFO "๐งช (dry-run) Would remove original input file: $input_file"
+ return 0
+ fi
+
+ if (( ${#direct_source_set[@]} > 0 )); then
+ local source
+ for source in "${direct_source_set[@]}"; do
+ log INFO "๐๏ธ Removing validated descriptor source: $source"
+ rm -f -- "$source"
+ done
+ else
+ log INFO "๐๏ธ Removing original input file: $input_file"
+ rm -f -- "$input_file"
+ fi
}
- # One-shot traps: cleanup on normal return and on error; clear both on return.
- trap '_cleanup; trap - RETURN; trap - ERR' RETURN ERR
if is_in_list "$ext" "${archive_exts[@]}"; then
archives_processed=$((archives_processed + 1))
case "$ext" in
- zip) mapfile -t archive_entries < <(unzip -Z1 "$input_file" | grep -Ei "$ext_regex") ;;
- rar) mapfile -t archive_entries < <(unrar lb "$input_file" | grep -Ei "$ext_regex") ;;
- 7z|7zip) mapfile -t archive_entries < <(7z l -ba "$input_file" | grep -Ei "$ext_regex") ;;
+ zip)
+ if archive_listing="$(unzip -Z1 -- "$input_file")"; then :; else archive_listing_exit=$?; fi
+ ;;
+ rar)
+ if archive_listing="$(unrar lb -- "$input_file")"; then :; else archive_listing_exit=$?; fi
+ ;;
+ 7z|7zip)
+ if archive_listing="$(7z l -slt -- "$input_file")"; then :; else archive_listing_exit=$?; fi
+ ;;
esac
- for entry in "${archive_entries[@]}"; do
- expected_chds+=("$(get_chd_basename "$entry").chd")
- done
+
+ if [[ $archive_listing_exit -ne 0 ]]; then
+ log ERROR "โ Archive listing failed for $input_file (Exit code: $archive_listing_exit). Skipping."
+ _return_failed_input
+ return 1
+ fi
+
+ case "$ext" in
+ zip|rar) mapfile -t archive_all_entries <<< "$archive_listing" ;;
+ # The first Path field describes the archive itself. Member records
+ # begin after the separator in 7z's technical listing.
+ 7z|7zip) mapfile -t archive_all_entries < <(awk 'seen && /^Path = /{print substr($0,8)} /^----------$/{seen=1}' <<< "$archive_listing") ;;
+ esac
+ if ! validate_archive_member_paths "${archive_all_entries[@]}"; then
+ log ERROR "โ Archive path preflight failed: $input_file"
+ _return_failed_input
+ return 1
+ fi
+
+ case "$ext" in
+ zip|rar) mapfile -t archive_entries < <(grep -Ei "$ext_regex" <<< "$archive_listing" || true) ;;
+ 7z|7zip) mapfile -t archive_entries < <(awk -v IGNORECASE=1 -v re="$ext_regex" 'seen && /^Path = /{p=substr($0,8); if(p~re) print p} /^----------$/{seen=1}' <<< "$archive_listing") ;;
+ esac
+
+ if [[ ${#archive_entries[@]} -gt 0 ]]; then
+ mapfile -t archive_entries < <(select_preferred_disc_candidates "${archive_entries[@]}")
+ if ! validate_archive_output_names "${archive_entries[@]}"; then
+ _return_failed_input
+ return 1
+ fi
+ log DEBUG "๐ Selected ${#archive_entries[@]} preferred disc descriptor(s) from archive: $(basename "$input_file")"
+ for entry in "${archive_entries[@]}"; do
+ local expected_chd
+ expected_chd="$(archive_entry_to_chd_name "$entry")"
+ log DEBUG " Selected archive entry: $entry"
+ log DEBUG " Expected CHD: $expected_chd"
+ expected_chds+=("$expected_chd")
+ done
+ fi
fi
if is_in_list "$ext" "${disc_exts[@]}"; then
@@ -1094,26 +1463,42 @@ process_input() {
return 0
fi
- # If all expected CHDs already exist and verify, remove original and done
- if verify_chds "$outdir" "${expected_chds[@]}"; then
- log INFO "โ
All expected CHDs verified for $input_file"
- if [[ "$KEEP_ORIGINALS" != true ]]; then
- if [[ "$DRY_RUN" == true ]]; then
- log INFO "๐งช (dry-run) Would remove original input file: $input_file"
- else
- log INFO "๐๏ธ Removing original input file: $input_file"
- rm -f -- "$input_file"
- fi
+ # Direct descriptors own a complete validated source set. On success, all
+ # members are removed together; on any failure, every source is retained.
+ if [[ "$ext" == "cue" || "$ext" == "gdi" || "$ext" == "ccd" ]]; then
+ if ! validate_descriptor_file "$input_file"; then
+ log ERROR "โ Descriptor validation failed (input considered failed): $input_file"
+ input_failed=true
else
- log INFO "๐ฆ Keeping original input file due to KEEP_ORIGINALS=true"
+ direct_source_set=("${DESCRIPTOR_SOURCE_SET[@]}")
fi
- # Per-iteration M3U generation for already-present sets
+ fi
+ if [[ "$input_failed" == true ]]; then
+ _return_failed_input
+ return 1
+ fi
+
+ # Establish one authoritative state for every expected output.
+ local expected_chd
+ for expected_chd in "${expected_chds[@]}"; do
+ if [[ -f "$outdir/$expected_chd" ]] && verify_chds "$outdir" "$expected_chd"; then
+ output_states["$expected_chd"]="already_verified"
+ else
+ output_states["$expected_chd"]="missing"
+ fi
+ done
+
+ # If all expected CHDs already exist and verify, generate the complete-set
+ # playlist before treating source removal as the final action.
+ if [[ "$input_failed" != true ]] && _all_outputs_complete; then
+ log INFO "โ
All expected CHDs verified for $input_file"
if [[ ${#expected_chds[@]} -gt 0 ]]; then
local chd_base
chd_base="$(basename "${expected_chds[0]}" .chd)"
log DEBUG "๐ค Raw base name: $chd_base"
maybe_generate_m3u_for "$chd_base" "$outdir"
fi
+ _remove_input_if_allowed
return 0
fi
@@ -1127,25 +1512,55 @@ process_input() {
# But keep expected_chds populated from archive listing (already done above).
else
temp_dir="$(mktemp -d -p "$TMPDIR" "chdconv_$(basename "$input_file" ".${ext}")_XXXX")"
+ track_temp_dir "$temp_dir"
log INFO "๐ฆ Extracting $input_file to $temp_dir"
- TEMP_DIRS+=("$temp_dir")
+ local extraction_exit=0
case "$ext" in
- zip) unzip -qq "$input_file" -d "$temp_dir" ;;
- rar) unrar x -o+ "$input_file" "$temp_dir" >/dev/null ;;
- 7z|7zip) 7z x -y -o"$temp_dir" "$input_file" >/dev/null ;;
+ zip)
+ unzip -qq "$input_file" -d "$temp_dir"
+ extraction_exit=$?
+ ;;
+ rar)
+ unrar x -o+ "$input_file" "$temp_dir" >/dev/null
+ extraction_exit=$?
+ ;;
+ 7z|7zip)
+ 7z x -y -o"$temp_dir" "$input_file" >/dev/null
+ extraction_exit=$?
+ ;;
esac
- log DEBUG "๐งน Flushing extraction buffers to free up RAM..."
- sync
+ # Strict validation: Abort if the extraction tool retrned an error code, which likely means the archive is corrupted or password-protected.
+ if [[ $extraction_exit -ne 0 ]]; then
+ log ERROR "โ Extraction failed for $input_file (Exit code: $extraction_exit). Skipping."
+ input_failed=true
+ cleanup_temp_dir_now "$temp_dir"
+ _return_failed_input
+ return 1
+ fi
+
+ if ! validate_extracted_tree "$temp_dir"; then
+ input_failed=true
+ cleanup_temp_dir_now "$temp_dir"
+ _return_failed_input
+ return 1
+ fi
read -r -a disc_find_expr <<< "$(build_find_expr "${disc_exts[@]}")"
mapfile -d '' -t disc_files < <(find "$temp_dir" -type f \( "${disc_find_expr[@]}" \) -print0)
- if [[ ${#disc_files[@]} -eq 0 && ${#archive_entries[@]} -gt 0 ]]; then
+ if [[ ${#disc_files[@]} -gt 0 ]]; then
+ mapfile -t disc_files < <(select_preferred_disc_candidates "${disc_files[@]}")
+ log DEBUG "๐ Selected ${#disc_files[@]} preferred extracted disc file(s) from: $temp_dir"
+ for disc in "${disc_files[@]}"; do
+ log DEBUG " Selected extracted file: $disc"
+ done
+ elif [[ ${#archive_entries[@]} -gt 0 ]]; then
for entry in "${archive_entries[@]}"; do
local full_path="$temp_dir/$entry"
[[ -f "$full_path" ]] && disc_files+=("$full_path")
done
+ log DEBUG "๐ Falling back to archive-selected extracted entries: ${#disc_files[@]}"
fi
fi
@@ -1153,74 +1568,137 @@ process_input() {
if [[ "$DRY_RUN" == true && ${#archive_entries[@]} -gt 0 ]]; then
for entry in "${archive_entries[@]}"; do
# Mirror the naming used later: basename without extension + .chd
- log INFO "๐งช (dry-run) Would convert: $entry -> $outdir/$(basename "${entry%.*}").chd"
+ log INFO "๐งช (dry-run) Would convert: $entry -> $outdir/$(archive_entry_to_chd_name "$entry")"
done
fi
fi
local archive_chd_size=0
local tmp_chds=()
+ local final_chds=()
if [[ "$DRY_RUN" == true ]]; then
for disc in "${disc_files[@]}"; do
- log INFO "๐งช (dry-run) Would convert: $disc -> $outdir/$(basename "${disc%.*}").chd"
+ convert_disc_file "$disc" "$outdir" || input_failed=true
+ done
+
+ # Dry-run: also show M3U intent (if this looks like a multi-disc set)
+ if [[ ${#expected_chds[@]} -gt 0 ]]; then
+ local chd_base
+ chd_base="$(basename "${expected_chds[0]}" .chd)"
+ maybe_generate_m3u_for "$chd_base" "$outdir"
+ fi
+
+ [[ "$input_failed" == true ]] && { _return_failed_input; return 1; }
+ return 0
+ fi
+
+ # Real conversion
+ if is_in_list "$ext" "${archive_exts[@]}"; then
+ # Drive conversion from archive_entries so output naming matches expected_chds
+ for entry in "${archive_entries[@]}"; do
+ local extracted="$temp_dir/$entry"
+ [[ -f "$extracted" ]] || continue
+
+ local extracted_ext="${extracted##*.}"; extracted_ext="${extracted_ext,,}"
+ if [[ "$extracted_ext" == "cue" || "$extracted_ext" == "gdi" || "$extracted_ext" == "ccd" ]]; then
+ if ! validate_descriptor_file "$extracted"; then
+ log ERROR "โ Extracted descriptor validation failed: $entry"
+ input_failed=true
+ continue
+ fi
+ fi
+
+ local chd_name chd_base
+ chd_name="$(archive_entry_to_chd_name "$entry")" # e.g. "CD1 - Game.chd"
+ chd_base="${chd_name%.chd}" # e.g. "CD1 - Game"
+
+ [[ "${output_states[$chd_name]:-missing}" == "already_verified" ]] && continue
+
+ local converted_tmp=""
+ if convert_disc_file "$extracted" "$outdir" "$chd_base" converted_tmp; then
+ if [[ -n "$converted_tmp" ]]; then
+ tmp_chds+=("$converted_tmp")
+ final_chds+=("$outdir/$chd_name")
+ output_temps["$chd_name"]="$converted_tmp"
+ output_states["$chd_name"]="converted_pending_verification"
+ fi
+ else
+ output_states["$chd_name"]="failed"
+ input_failed=true
+ fi
done
+
+ # We no longer need the extracted archive contents at this point
+ cleanup_temp_dir_now "$temp_dir"
+ temp_dir=""
+
else
+ # Non-archive inputs keep the old behaviour
for disc in "${disc_files[@]}"; do
- if convert_disc_file "$disc" "$outdir"; then
- tmp_chds+=("$outdir/$(basename "${disc%.*}").chd.tmp")
+ local chd_name
+ chd_name="$(get_chd_basename "$disc").chd"
+ [[ "${output_states[$chd_name]:-missing}" == "already_verified" ]] && continue
+
+ local converted_tmp=""
+ if convert_disc_file "$disc" "$outdir" "" converted_tmp; then
+ if [[ -n "$converted_tmp" ]]; then
+ tmp_chds+=("$converted_tmp")
+ final_chds+=("$outdir/$chd_name")
+ output_temps["$chd_name"]="$converted_tmp"
+ output_states["$chd_name"]="converted_pending_verification"
+ fi
else
- failures=$((failures + 1))
+ output_states["$chd_name"]="failed"
+ input_failed=true
fi
done
fi
- # Verify .tmp CHDs and finalize
+ # Verify and finalise pending outputs independently. Existing verified
+ # outputs are never added to this temporary-output verification pass.
if [[ "$DRY_RUN" != true && ${#tmp_chds[@]} -gt 0 ]]; then
- if verify_chds "$outdir" "${tmp_chds[@]##*/}"; then
- for tmp_chd in "${tmp_chds[@]}"; do
- local final_chd="${tmp_chd%.tmp}"
+ local chd_name tmp_chd final_chd
+ for chd_name in "${expected_chds[@]}"; do
+ [[ "${output_states[$chd_name]:-missing}" == "converted_pending_verification" ]] || continue
+ tmp_chd="${output_temps[$chd_name]}"
+ final_chd="$outdir/$chd_name"
+ if verify_chds "" "$tmp_chd"; then
mv -f -- "$tmp_chd" "$final_chd"
+ untrack_temp_file "$tmp_chd"
+ cleanup_temp_dir_now "$(dirname "$tmp_chd")"
log INFO "๐ Replaced old CHD with new verified CHD: $final_chd"
chds_created=$((chds_created + 1))
- done
-
- if [[ "$KEEP_ORIGINALS" != true ]]; then
- if [[ "$DRY_RUN" == true ]]; then
- log INFO "๐งช (dry-run) Would remove original input file: $input_file"
- else
- log INFO "๐๏ธ Removing original input file: $input_file"
- rm -f -- "$input_file"
- fi
+ output_states["$chd_name"]="finalised"
else
- log INFO "๐ฆ Keeping original input file due to KEEP_ORIGINALS=true"
- fi
-
- for chd in "${expected_chds[@]}"; do
- if [[ -f "$outdir/$chd" ]]; then
- archive_chd_size=$((archive_chd_size + $(get_file_size "$outdir/$chd")))
- fi
- done
- if [[ $archive_size_bytes -gt 0 ]]; then
- local saving=$((archive_size_bytes - archive_chd_size))
- local saving_percent=$((100 * saving / archive_size_bytes))
- log INFO "๐ Space saving for $(basename "$input_file"): $(human_readable "$archive_size_bytes") โ $(human_readable "$archive_chd_size"), saved $(human_readable "$saving") (${saving_percent}%)"
- total_original_size=$((total_original_size + archive_size_bytes))
- total_chd_size=$((total_chd_size + archive_chd_size))
+ cleanup_temp_file_now "$tmp_chd"
+ cleanup_temp_dir_now "$(dirname "$tmp_chd")"
+ output_states["$chd_name"]="failed"
+ input_failed=true
+ log WARN "โ ๏ธ CHD verification failed after conversion for $chd_name"
fi
- else
- for tmp_chd in "${tmp_chds[@]}"; do
- if [[ -f "$tmp_chd" ]]; then
- rm -f "$tmp_chd"
- log INFO "๐๏ธ Removed failed tmp CHD: $tmp_chd"
- fi
- done
- failures=$((failures + 1))
- log WARN "โ ๏ธ CHD verification failed after conversion for $input_file, keeping original"
- fi
+ done
+ fi
+
+ if [[ "$input_failed" == true ]] || ! _all_outputs_complete; then
+ log WARN "โ ๏ธ Not all expected members converted successfully for $input_file, keeping original"
+ _return_failed_input
+ return 1
+ fi
+
+ # Source deletion, accounting, and M3U generation all consume the same
+ # complete manifest rather than independently inferring success.
+ for chd_name in "${expected_chds[@]}"; do
+ archive_chd_size=$((archive_chd_size + $(get_file_size "$outdir/$chd_name")))
+ done
+ if [[ $archive_size_bytes -gt 0 ]]; then
+ local saving=$((archive_size_bytes - archive_chd_size))
+ local saving_percent=$((100 * saving / archive_size_bytes))
+ log INFO "๐ Space saving for $(basename "$input_file"): $(human_readable "$archive_size_bytes") โ $(human_readable "$archive_chd_size"), saved $(human_readable "$saving") (${saving_percent}%)"
+ total_original_size=$((total_original_size + archive_size_bytes))
+ total_chd_size=$((total_chd_size + archive_chd_size))
fi
- # Per-iteration M3U generation for newly written CHDs
if [[ ${#expected_chds[@]} -gt 0 ]]; then
local chd_base
chd_base="$(basename "${expected_chds[0]}" .chd)"
@@ -1228,6 +1706,8 @@ process_input() {
maybe_generate_m3u_for "$chd_base" "$outdir"
fi
+ _remove_input_if_allowed
+
return 0
}
@@ -1252,9 +1732,7 @@ log INFO "๐ Found ${#all_inputs[@]} inputs"
for input in "${all_inputs[@]}"; do
log INFO "โถ๏ธ Processing file: $input"
- if ! process_input "$input"; then
- log ERROR "โ ๏ธ Failed to process $input"
- fi
+ process_input "$input" || log ERROR "โ ๏ธ Failed to process $input"
done
log INFO "๐ Summary:"
@@ -1269,4 +1747,10 @@ log INFO "๐ฆ Archives processed: $archives_processed"
log INFO "๐ฟ CHDs created: $chds_created"
log INFO "โ Failures: $failures"
log INFO "โฑ๏ธ Elapsed time: $(format_duration $(( $(date +%s) - script_start_time )))"
-log INFO "โ
Done!"
+if [[ $failures -gt 0 ]]; then
+ log ERROR "โ ๏ธ Completed with failures ($failures input(s) failed)."
+ exit 2
+fi
+
+log INFO "โ
Completed successfully."
+exit 0
diff --git a/dist/chdtool-v0.2.3.tar.gz b/dist/chdtool-v0.2.3.tar.gz
deleted file mode 100644
index de353dd..0000000
Binary files a/dist/chdtool-v0.2.3.tar.gz and /dev/null differ
diff --git a/dist/chdtool-v0.2.4.tar.gz b/dist/chdtool-v0.2.4.tar.gz
deleted file mode 100644
index b97fef1..0000000
Binary files a/dist/chdtool-v0.2.4.tar.gz and /dev/null differ
diff --git a/dist/chdtool-v0.2.5.tar.gz b/dist/chdtool-v0.2.5.tar.gz
deleted file mode 100644
index 86706e4..0000000
Binary files a/dist/chdtool-v0.2.5.tar.gz and /dev/null differ
diff --git a/scripts/package-release.sh b/scripts/package-release.sh
new file mode 100644
index 0000000..df569c3
--- /dev/null
+++ b/scripts/package-release.sh
@@ -0,0 +1,73 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$REPO_ROOT"
+
+version="${1:-}"
+[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || {
+ echo "Usage: $0 " >&2
+ exit 1
+}
+
+script_version="$(sed -n 's/^CHDTOOL_VERSION="\([^"]*\)"/\1/p' chdtool.sh)"
+[[ "$script_version" == "$version" ]] || {
+ echo "Release version $version does not match chdtool.sh version $script_version" >&2
+ exit 1
+}
+
+for command in tar gzip zip unzip sha256sum; do
+ command -v "$command" >/dev/null 2>&1 || { echo "Missing packaging command: $command" >&2; exit 1; }
+done
+
+archive_base="chdtool-v$version"
+dist_dir="${DIST_DIR:-$REPO_ROOT/dist}"
+[[ "$dist_dir" == /* ]] || dist_dir="$REPO_ROOT/$dist_dir"
+[[ "$dist_dir" != "/" && "$dist_dir" != "$REPO_ROOT" ]] || {
+ echo "Refusing unsafe distribution directory: $dist_dir" >&2
+ exit 1
+}
+stage_root="$(mktemp -d)"
+trap 'rm -rf "$stage_root"' EXIT
+stage_dir="$stage_root/$archive_base"
+mkdir -p "$stage_dir"
+
+install -m 0755 chdtool.sh "$stage_dir/chdtool"
+install -m 0644 README.md LICENSE.md CHANGELOG.md "$stage_dir/"
+
+# Use the tagged commit timestamp when available, with a stable fallback for
+# source archives or local packaging outside a Git checkout.
+source_date_epoch="${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2>/dev/null || printf '0')}"
+find "$stage_dir" -exec touch -d "@$source_date_epoch" {} +
+
+rm -rf -- "$dist_dir"
+mkdir -p "$dist_dir"
+
+tar --sort=name --mtime="@$source_date_epoch" --owner=0 --group=0 --numeric-owner \
+ -C "$stage_root" -cf - "$archive_base" | gzip -n > "$dist_dir/$archive_base.tar.gz"
+
+zip_path="$dist_dir/$archive_base.zip"
+(
+ cd "$stage_root"
+ find "$archive_base" -type f -print | LC_ALL=C sort | zip -X -q "$zip_path" -@
+)
+
+install -m 0755 chdtool.sh "$dist_dir/chdtool"
+
+expected="$(printf '%s\n' \
+ "$archive_base/CHANGELOG.md" \
+ "$archive_base/LICENSE.md" \
+ "$archive_base/README.md" \
+ "$archive_base/chdtool")"
+tar_contents="$(tar -tzf "$dist_dir/$archive_base.tar.gz" | sed '/\/$/d' | LC_ALL=C sort)"
+zip_contents="$(unzip -Z1 "$dist_dir/$archive_base.zip" | sed '/\/$/d' | LC_ALL=C sort)"
+[[ "$tar_contents" == "$expected" ]] || { echo "Unexpected tar archive contents" >&2; exit 1; }
+[[ "$zip_contents" == "$expected" ]] || { echo "Unexpected zip archive contents" >&2; exit 1; }
+
+(
+ cd "$dist_dir"
+ sha256sum chdtool "$archive_base.tar.gz" "$archive_base.zip" > SHA256SUMS
+ sha256sum --check SHA256SUMS
+)
+
+printf 'Created validated release assets in %s\n' "$dist_dir"
diff --git a/tests/bin/chdman b/tests/bin/chdman
index a2c4063..f061ed1 100755
--- a/tests/bin/chdman
+++ b/tests/bin/chdman
@@ -6,14 +6,52 @@ case "$1" in
echo "chdman 0.000 (stub) - createdvd createcd verify"; exit 0
;;
verify)
+ verify_input=""
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -i) verify_input="$2"; shift 2 ;;
+ *) shift ;;
+ esac
+ done
+ [[ -n "${CHDMAN_VERIFY_LOG:-}" ]] && printf '%s\n' "$verify_input" >> "$CHDMAN_VERIFY_LOG"
+ if [[ -n "${CHDMAN_FAIL_VERIFY_INPUT:-}" && "$verify_input" == *"$CHDMAN_FAIL_VERIFY_INPUT"* ]]; then
+ echo "stub chdman: requested verification failure for $verify_input" >&2
+ exit 2
+ fi
+ if [[ -f "$verify_input" ]] && grep -q '^INVALID$' "$verify_input"; then
+ echo "invalid CHD" >&2
+ exit 2
+ fi
# your script greps for "complete" and optionally "(ratio=..%)"
echo "Verifying, 100% complete (ratio=50%)" >&2
exit 0
;;
createcd|createdvd)
- # not used in the test; if called, fail fast so we notice
- echo "stub chdman: create not supported in test" >&2
- exit 2
+ [[ -n "${CHDMAN_ARGS_LOG:-}" ]] && printf '%s\n' "$*" >> "$CHDMAN_ARGS_LOG"
+ input=""
+ output=""
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -i) input="$2"; shift 2 ;;
+ -o) output="$2"; shift 2 ;;
+ *) shift ;;
+ esac
+ done
+ if [[ -n "${CHDMAN_READY_FILE:-}" ]]; then
+ printf '%s\n' "$output" > "$CHDMAN_READY_FILE"
+ fi
+ if [[ -n "${CHDMAN_PAUSE_SECONDS:-}" ]]; then
+ sleep "$CHDMAN_PAUSE_SECONDS"
+ fi
+ if [[ -n "${CHDMAN_FAIL_INPUT:-}" && "$input" == *"$CHDMAN_FAIL_INPUT"* ]]; then
+ : > "$output"
+ echo "stub chdman: requested failure for $input" >&2
+ exit 2
+ fi
+ [[ -n "${CHDMAN_CREATE_LOG:-}" ]] && printf '%s\n' "$input" >> "$CHDMAN_CREATE_LOG"
+ printf 'converted:%s\n' "$input" > "$output"
+ echo "Compressing, 100% complete (ratio=50%)" >&2
+ exit 0
;;
*)
echo "stub chdman: unknown args: $*" >&2
diff --git a/tests/test_cli.sh b/tests/test_cli.sh
new file mode 100644
index 0000000..816169c
--- /dev/null
+++ b/tests/test_cli.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SCRIPT="${SCRIPT:-$REPO_ROOT/chdtool.sh}"
+if [[ "$SCRIPT" != /* ]]; then
+ SCRIPT="$(cd "$(dirname "$SCRIPT")" && pwd)/$(basename "$SCRIPT")"
+fi
+FIX="$(mktemp -d)"
+trap 'rm -rf "$FIX"' EXIT
+export PATH="$REPO_ROOT/tests/bin:$PATH"
+
+help_output="$(bash "$SCRIPT" --help)"
+grep -q '^Usage:' <<< "$help_output" || { echo "FAIL: --help omitted usage" >&2; exit 1; }
+grep -q -- '--allow-unverified-cue-audio' <<< "$help_output" || { echo "FAIL: --help omitted audio warning option" >&2; exit 1; }
+
+version_output="$(bash "$SCRIPT" --version)"
+[[ "$version_output" =~ ^chdtool\.sh\ [0-9]+\.[0-9]+\.[0-9]+ ]] || { echo "FAIL: unexpected --version output: $version_output" >&2; exit 1; }
+
+mkdir "$FIX/-input"
+(
+ cd "$FIX"
+ cli_output="$(LOG_DEST=console LOG_LEVEL_THRESHOLD=ERROR bash "$SCRIPT" --dry-run --no-file-tee -- -input 2>&1)"
+ [[ "$cli_output" != *'unknown predicate'* ]] || { echo "FAIL: leading-dash path reached find as an option" >&2; exit 1; }
+)
+[[ ! -e "$FIX/logs" ]] || { echo "FAIL: --no-file-tee created a console-backend logfile" >&2; exit 1; }
+
+if TMPDIR="$FIX/dry-temp" LOG_DEST=console LOG_LEVEL_THRESHOLD=ERROR \
+ bash "$SCRIPT" --dry-run --no-file-tee "$FIX/-input"; then
+ [[ ! -e "$FIX/dry-temp" ]] || { echo "FAIL: dry-run created a temporary workspace" >&2; exit 1; }
+else
+ echo "FAIL: dry-run CLI invocation failed" >&2
+ exit 1
+fi
+
+echo "PASS: help, version, option terminator, logging, and dry-run CLI behaviour"
diff --git a/tests/test_exit_status.sh b/tests/test_exit_status.sh
new file mode 100644
index 0000000..0256fdc
--- /dev/null
+++ b/tests/test_exit_status.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SCRIPT="${SCRIPT:-$REPO_ROOT/chdtool.sh}"
+FIX="$(mktemp -d)"
+trap 'rm -rf "$FIX"' EXIT
+
+export PATH="$REPO_ROOT/tests/bin:$PATH"
+export PROGRESS_STYLE=none
+export LOG_DEST=console
+export LOG_LEVEL_THRESHOLD=INFO
+
+run_and_capture() {
+ local output_file="$1"; shift
+ set +e
+ "$@" >"$output_file" 2>&1
+ RUN_STATUS=$?
+ set -e
+}
+
+mkdir "$FIX/success"
+: > "$FIX/success/Success Game.iso"
+run_and_capture "$FIX/success.log" bash "$SCRIPT" "$FIX/success"
+[[ $RUN_STATUS -eq 0 ]] || { echo "FAIL: success returned $RUN_STATUS, expected 0" >&2; exit 1; }
+grep -q 'Completed successfully' "$FIX/success.log" || { echo "FAIL: clean completion log missing" >&2; exit 1; }
+
+run_and_capture "$FIX/startup.log" bash "$SCRIPT" "$FIX/does-not-exist"
+[[ $RUN_STATUS -eq 1 ]] || { echo "FAIL: startup failure returned $RUN_STATUS, expected 1" >&2; exit 1; }
+
+mkdir "$FIX/listing" "$FIX/listing-bin"
+: > "$FIX/listing/Broken.zip"
+cat > "$FIX/listing-bin/unzip" <<'STUB'
+#!/usr/bin/env bash
+echo "stub unzip: unreadable archive" >&2
+exit 9
+STUB
+chmod +x "$FIX/listing-bin/unzip"
+run_and_capture "$FIX/listing.log" env PATH="$FIX/listing-bin:$PATH" bash "$SCRIPT" "$FIX/listing"
+[[ $RUN_STATUS -eq 2 ]] || { echo "FAIL: archive listing failure returned $RUN_STATUS, expected 2" >&2; exit 1; }
+grep -q 'Archive listing failed' "$FIX/listing.log" || { echo "FAIL: listing failure was not reported explicitly" >&2; exit 1; }
+if grep -q 'no disc files found' "$FIX/listing.log"; then
+ echo "FAIL: listing failure was reported as an empty archive" >&2
+ exit 1
+fi
+grep -q 'Completed with failures' "$FIX/listing.log" || { echo "FAIL: failed completion log missing" >&2; exit 1; }
+
+echo "PASS: success, startup, and archive-listing exit statuses"
diff --git a/tests/test_package.sh b/tests/test_package.sh
new file mode 100644
index 0000000..115917a
--- /dev/null
+++ b/tests/test_package.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+version="$(sed -n 's/^CHDTOOL_VERSION="\([^"]*\)"/\1/p' "$REPO_ROOT/chdtool.sh")"
+FIX="$(mktemp -d)"
+trap 'rm -rf "$FIX"' EXIT
+dist_dir="$FIX/dist"
+
+(cd "$REPO_ROOT" && DIST_DIR="$dist_dir" SOURCE_DATE_EPOCH=0 bash scripts/package-release.sh "$version")
+
+archive_base="chdtool-v$version"
+[[ -s "$dist_dir/$archive_base.tar.gz" ]] || { echo "FAIL: tar archive missing" >&2; exit 1; }
+[[ -s "$dist_dir/$archive_base.zip" ]] || { echo "FAIL: zip archive missing" >&2; exit 1; }
+[[ -s "$dist_dir/chdtool" && -s "$dist_dir/SHA256SUMS" ]] || { echo "FAIL: standalone script or checksums missing" >&2; exit 1; }
+(cd "$dist_dir" && sha256sum --check SHA256SUMS >/dev/null)
+first_hashes="$(cd "$dist_dir" && sha256sum chdtool "$archive_base.tar.gz" "$archive_base.zip")"
+
+(cd "$REPO_ROOT" && DIST_DIR="$dist_dir" SOURCE_DATE_EPOCH=0 bash scripts/package-release.sh "$version" >/dev/null)
+second_hashes="$(cd "$dist_dir" && sha256sum chdtool "$archive_base.tar.gz" "$archive_base.zip")"
+[[ "$first_hashes" == "$second_hashes" ]] || { echo "FAIL: repeated packaging was not reproducible" >&2; exit 1; }
+
+echo "PASS: release assets are complete, validated, and checksummed"
diff --git a/tests/test_partial_resume.sh b/tests/test_partial_resume.sh
new file mode 100644
index 0000000..b6bc78f
--- /dev/null
+++ b/tests/test_partial_resume.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SCRIPT="${SCRIPT:-$REPO_ROOT/chdtool.sh}"
+FIX="$(mktemp -d)"
+trap 'rm -rf "$FIX"' EXIT
+export PATH="$REPO_ROOT/tests/bin:$PATH"
+export PROGRESS_STYLE=none LOG_DEST=console LOG_LEVEL_THRESHOLD=ERROR
+
+make_archive() {
+ local archive="$1"; shift
+ (cd "$FIX" && zip -q "$(basename "$archive")" "$@" && rm -f -- "$@")
+}
+
+printf 'existing-valid\n' > "$FIX/Resume Game (Disc 1).chd"
+: > "$FIX/Resume Game (Disc 1).iso"
+: > "$FIX/Resume Game (Disc 2).iso"
+archive="$FIX/resume-game.zip"
+make_archive "$archive" "Resume Game (Disc 1).iso" "Resume Game (Disc 2).iso"
+create_log="$FIX/create.log"
+CHDMAN_CREATE_LOG="$create_log" bash "$SCRIPT" "$FIX"
+[[ "$(cat "$FIX/Resume Game (Disc 1).chd")" == "existing-valid" ]] || { echo "FAIL: valid CHD replaced" >&2; exit 1; }
+if [[ "$(wc -l < "$create_log")" -ne 1 ]] || ! grep -q 'Disc 2' "$create_log"; then
+ echo "FAIL: expected only Disc 2 conversion" >&2
+ exit 1
+fi
+[[ -f "$FIX/Resume Game (Disc 2).chd" && -f "$FIX/Resume Game.m3u" && ! -e "$archive" ]] || { echo "FAIL: resumed set incomplete" >&2; exit 1; }
+
+printf 'INVALID\n' > "$FIX/Replace Game.chd"
+: > "$FIX/Replace Game.iso"
+archive="$FIX/replace-game.zip"
+make_archive "$archive" "Replace Game.iso"
+: > "$create_log"
+CHDMAN_CREATE_LOG="$create_log" bash "$SCRIPT" "$FIX"
+[[ "$(wc -l < "$create_log")" -eq 1 ]] || { echo "FAIL: invalid CHD not converted once" >&2; exit 1; }
+grep -q '^converted:' "$FIX/Replace Game.chd" || { echo "FAIL: invalid CHD not replaced" >&2; exit 1; }
+[[ ! -e "$archive" ]] || { echo "FAIL: source retained after replacement" >&2; exit 1; }
+
+echo "PASS: partial resume uses explicit output states"
diff --git a/tests/test_regression_gaps.sh b/tests/test_regression_gaps.sh
new file mode 100644
index 0000000..5a9e4fd
--- /dev/null
+++ b/tests/test_regression_gaps.sh
@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SCRIPT="${SCRIPT:-$REPO_ROOT/chdtool.sh}"
+FIX="$(mktemp -d)"
+trap 'rm -rf "$FIX"' EXIT
+
+export PATH="$REPO_ROOT/tests/bin:$PATH"
+export PROGRESS_STYLE=none LOG_DEST=console LOG_LEVEL_THRESHOLD=ERROR
+
+run_and_capture() {
+ local output_file="$1"; shift
+ set +e
+ "$@" >"$output_file" 2>&1
+ RUN_STATUS=$?
+ set -e
+}
+
+assert_no_temporary_chds() {
+ local directory="$1"
+ if find "$directory" -name '*.chd.tmp.*' -print -quit | grep -q .; then
+ echo "FAIL: temporary CHD remained in $directory" >&2
+ find "$directory" -name '*.chd.tmp.*' -print >&2
+ exit 1
+ fi
+}
+
+# Listing succeeds, but extraction fails: report the extraction error, retain the
+# archive, and do not create either final or temporary conversion output.
+mkdir -p "$FIX/extraction" "$FIX/extraction-bin"
+: > "$FIX/extraction/Broken.zip"
+cat > "$FIX/extraction-bin/unzip" <<'STUB'
+#!/usr/bin/env bash
+if [[ " $* " == *" -Z1 "* ]]; then
+ printf '%s\n' 'Extraction Game.iso'
+ exit 0
+fi
+echo 'stub unzip: requested extraction failure' >&2
+exit 7
+STUB
+chmod +x "$FIX/extraction-bin/unzip"
+run_and_capture "$FIX/extraction.log" env PATH="$FIX/extraction-bin:$PATH" bash "$SCRIPT" "$FIX/extraction"
+[[ $RUN_STATUS -eq 2 ]] || { echo "FAIL: extraction failure returned $RUN_STATUS, expected 2" >&2; exit 1; }
+grep -q 'Extraction failed' "$FIX/extraction.log" || { echo "FAIL: extraction failure was not reported" >&2; exit 1; }
+[[ -f "$FIX/extraction/Broken.zip" && ! -e "$FIX/extraction/Extraction Game.chd" ]] || {
+ echo "FAIL: extraction failure changed source/final output state" >&2; exit 1;
+}
+assert_no_temporary_chds "$FIX/extraction"
+
+# A newly converted CHD that fails both verification attempts must be removed,
+# leave its source intact, and make the overall process fail.
+mkdir "$FIX/verification"
+: > "$FIX/verification/Verify Game.iso"
+verify_log="$FIX/verification-attempts.log"
+run_and_capture "$FIX/verification.log" env \
+ CHDMAN_FAIL_VERIFY_INPUT='Verify Game.chd' CHDMAN_VERIFY_LOG="$verify_log" \
+ bash "$SCRIPT" "$FIX/verification"
+[[ $RUN_STATUS -eq 2 ]] || { echo "FAIL: double verification failure returned $RUN_STATUS, expected 2" >&2; exit 1; }
+[[ "$(wc -l < "$verify_log")" -eq 2 ]] || { echo "FAIL: expected exactly two verification attempts" >&2; exit 1; }
+[[ -f "$FIX/verification/Verify Game.iso" && ! -e "$FIX/verification/Verify Game.chd" ]] || {
+ echo "FAIL: unverified output was finalised or its source removed" >&2; exit 1;
+}
+assert_no_temporary_chds "$FIX/verification"
+
+# A caller-supplied logfile path is authoritative.
+mkdir "$FIX/custom-log-input"
+: > "$FIX/custom-log-input/Custom Log Game.iso"
+custom_log="$FIX/custom/logs/conversion.log"
+run_and_capture "$FIX/custom-log.stderr" env LOG_DEST=file LOGFILE="$custom_log" \
+ LOG_LEVEL_THRESHOLD=INFO bash "$SCRIPT" --dry-run "$FIX/custom-log-input"
+[[ $RUN_STATUS -eq 0 ]] || { echo "FAIL: custom LOGFILE run returned $RUN_STATUS" >&2; cat "$FIX/custom-log.stderr" "$custom_log" >&2; exit 1; }
+[[ -s "$custom_log" ]] || { echo "FAIL: custom LOGFILE was not created" >&2; exit 1; }
+grep -q 'Script started' "$custom_log" || { echo "FAIL: custom LOGFILE is missing run output" >&2; exit 1; }
+
+# Dry-run may log outside the source directory, but must not mutate anything in
+# the input tree (including conversion output, playlists, or temporary files).
+mkdir "$FIX/dry-run"
+: > "$FIX/dry-run/Dry Game.iso"
+before="$(find "$FIX/dry-run" -mindepth 1 -printf '%P|%s|%T@\n' | sort)"
+LOG_DEST=file LOGFILE="$FIX/dry-run.log" bash "$SCRIPT" --dry-run "$FIX/dry-run"
+after="$(find "$FIX/dry-run" -mindepth 1 -printf '%P|%s|%T@\n' | sort)"
+[[ "$before" == "$after" ]] || { echo "FAIL: dry-run mutated the input tree" >&2; diff <(printf '%s\n' "$before") <(printf '%s\n' "$after") >&2 || true; exit 1; }
+
+# Independent RUN_ID/TMPDIR/log paths keep repeated parallel invocations from
+# colliding. Run enough jobs to expose shared-name or cleanup races cheaply.
+pids=()
+for index in 1 2 3 4 5 6; do
+ job_dir="$FIX/parallel-$index"
+ mkdir "$job_dir"
+ : > "$job_dir/Parallel Game $index.iso"
+ LOG_DEST=file LOGFILE="$FIX/parallel-$index.log" RUN_ID="regression-$index" \
+ TMPDIR="$FIX/tmp-$index" bash "$SCRIPT" "$job_dir" &
+ pids+=("$!")
+done
+for pid in "${pids[@]}"; do
+ wait "$pid" || { echo "FAIL: parallel invocation $pid failed" >&2; exit 1; }
+done
+for index in 1 2 3 4 5 6; do
+ [[ -f "$FIX/parallel-$index/Parallel Game $index.chd" && ! -e "$FIX/parallel-$index/Parallel Game $index.iso" ]] || {
+ echo "FAIL: parallel invocation $index produced an unstable result" >&2; exit 1;
+ }
+ assert_no_temporary_chds "$FIX/parallel-$index"
+done
+
+echo "PASS: extraction, verification, custom logging, dry-run, and parallel regressions"
diff --git a/tests/test_resource_handling.sh b/tests/test_resource_handling.sh
new file mode 100644
index 0000000..e648e2e
--- /dev/null
+++ b/tests/test_resource_handling.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SCRIPT="${SCRIPT:-$REPO_ROOT/chdtool.sh}"
+FIX="$(mktemp -d)"
+trap 'rm -rf "$FIX"' EXIT
+export PATH="$REPO_ROOT/tests/bin:$PATH" PROGRESS_STYLE=none LOG_DEST=console LOG_LEVEL_THRESHOLD=ERROR
+
+mkdir "$FIX/default" "$FIX/override" "$FIX/dry"
+: > "$FIX/default/Default.iso"
+CHDMAN_ARGS_LOG="$FIX/default.args" bash "$SCRIPT" --keep-originals "$FIX/default" >/dev/null
+! grep -q -- ' -hs ' "$FIX/default.args" || { echo "FAIL: default conversion forced a hunk size" >&2; exit 1; }
+
+: > "$FIX/override/Override.iso"
+CHDMAN_ARGS_LOG="$FIX/override.args" CHDMAN_THREADS=999999 CHDMAN_HUNK_SIZE=8192 \
+ bash "$SCRIPT" --keep-originals "$FIX/override" >/dev/null
+grep -q -- '-hs 8192' "$FIX/override.args" || { echo "FAIL: hunk override missing" >&2; exit 1; }
+threads="$(sed -n 's/.*-np \([0-9][0-9]*\).*/\1/p' "$FIX/override.args")"
+(( threads >= 1 && threads <= $(nproc) )) || { echo "FAIL: thread override was not CPU-bounded" >&2; exit 1; }
+
+: > "$FIX/dry/Dry.iso"
+dry_output="$(LOG_LEVEL_THRESHOLD=INFO CHDMAN_THREADS=1 CHDMAN_HUNK_SIZE=4096 bash "$SCRIPT" --dry-run "$FIX/dry" 2>&1)"
+grep -q -- '-np 1 -hs 4096 -i' <<< "$dry_output" || { echo "FAIL: dry-run command omitted effective options" >&2; exit 1; }
+
+echo "PASS: resource selection and chdman command construction"
diff --git a/tests/test_security_validation.sh b/tests/test_security_validation.sh
new file mode 100644
index 0000000..bd11da4
--- /dev/null
+++ b/tests/test_security_validation.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SCRIPT="${SCRIPT:-$REPO_ROOT/chdtool.sh}"
+FIX="$(mktemp -d)"
+trap 'rm -rf "$FIX"' EXIT
+
+export PATH="$REPO_ROOT/tests/bin:$PATH"
+export PROGRESS_STYLE=none LOG_DEST=console LOG_LEVEL_THRESHOLD=ERROR
+
+expect_failure() {
+ local dir="$1"
+ set +e
+ bash "$SCRIPT" "$dir" >/dev/null 2>&1
+ local status=$?
+ set -e
+ [[ $status -eq 2 ]] || { echo "FAIL: $dir returned $status, expected 2" >&2; exit 1; }
+}
+
+# Safe nested references resolve component-by-component and successful direct
+# descriptor cleanup removes the complete validated source set.
+mkdir -p "$FIX/nested/Tracks"
+printf 'FILE "tracks/DATA.BIN" BINARY\n TRACK 01 MODE1/2352\n' > "$FIX/nested/Game.cue"
+: > "$FIX/nested/Tracks/data.bin"
+bash "$SCRIPT" "$FIX/nested" >/dev/null
+[[ -f "$FIX/nested/Game.chd" && ! -e "$FIX/nested/Game.cue" && ! -e "$FIX/nested/Tracks/data.bin" ]] || {
+ echo "FAIL: nested CUE conversion or source-set cleanup" >&2; exit 1;
+}
+
+mkdir -p "$FIX/parent" "$FIX/absolute"
+printf 'FILE "../outside.bin" BINARY\n' > "$FIX/parent/Unsafe.cue"
+: > "$FIX/outside.bin"
+printf 'FILE "/tmp/outside.bin" BINARY\n' > "$FIX/absolute/Unsafe.cue"
+expect_failure "$FIX/parent"
+expect_failure "$FIX/absolute"
+
+mkdir -p "$FIX/gdi" "$FIX/ccd"
+printf '1\n1 0 4 2352 missing.bin 0\n' > "$FIX/gdi/Missing.gdi"
+printf '[CloneCD]\nVersion=3\n' > "$FIX/ccd/Missing.ccd"
+: > "$FIX/ccd/Missing.img"
+expect_failure "$FIX/gdi"
+expect_failure "$FIX/ccd"
+
+# Both selected entries map to "A - B.chd" after sanitisation.
+mkdir -p "$FIX/collision/build"
+: > "$FIX/collision/build/A:B.iso"
+: > "$FIX/collision/build/A?B.iso"
+(cd "$FIX/collision/build" && zip -q ../collision.zip ./*)
+expect_failure "$FIX/collision"
+
+# A traversal member must be rejected from the listing before extraction.
+mkdir -p "$FIX/traversal/build"
+: > "$FIX/traversal/escape.iso"
+(cd "$FIX/traversal/build" && zip -q ../traversal.zip ../escape.iso)
+expect_failure "$FIX/traversal"
+
+echo "PASS: descriptor and archive security validation"
diff --git a/tests/test_temporary_chd_cleanup.sh b/tests/test_temporary_chd_cleanup.sh
new file mode 100644
index 0000000..f313246
--- /dev/null
+++ b/tests/test_temporary_chd_cleanup.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SCRIPT="${SCRIPT:-$REPO_ROOT/chdtool.sh}"
+FIX="$(mktemp -d)"
+trap 'rm -rf "$FIX"' EXIT
+
+export PATH="$REPO_ROOT/tests/bin:$PATH"
+export PROGRESS_STYLE=none
+export LOG_DEST=console
+export LOG_LEVEL_THRESHOLD=ERROR
+
+assert_no_temporary_chds() {
+ if find "$FIX" -name '*.chd.tmp.*' -print -quit | grep -q .; then
+ echo "FAIL: temporary CHD allocation remained in $FIX" >&2
+ find "$FIX" -name '*.chd.tmp.*' -print >&2
+ exit 1
+ fi
+}
+
+# A failed converter writes a partial output; chdtool must remove it immediately.
+: > "$FIX/Failure Game.iso"
+CHDMAN_FAIL_INPUT="Failure Game.iso" bash "$SCRIPT" "$FIX" || true
+assert_no_temporary_chds
+[[ ! -e "$FIX/Failure Game.chd" ]] || {
+ echo "FAIL: failed conversion produced a final CHD" >&2
+ exit 1
+}
+rm -f "$FIX/Failure Game.iso"
+
+# A signal while chdman is active must run the same tracked-file cleanup.
+: > "$FIX/Interrupted Game.iso"
+READY_FILE="$FIX/chdman.ready"
+CHDMAN_READY_FILE="$READY_FILE" CHDMAN_PAUSE_SECONDS=2 \
+ bash "$SCRIPT" "$FIX" &
+script_pid=$!
+
+for _ in {1..100}; do
+ [[ -s "$READY_FILE" ]] && break
+ sleep 0.05
+done
+[[ -s "$READY_FILE" ]] || {
+ echo "FAIL: timed out waiting for chdman to start" >&2
+ kill "$script_pid" 2>/dev/null || true
+ wait "$script_pid" 2>/dev/null || true
+ exit 1
+}
+
+tmp_path="$(tr -d '\r\n' < "$READY_FILE")"
+[[ "$tmp_path" == "$FIX"/* && "$tmp_path" == *'.chd.tmp.'*/*'.chd' ]] || {
+ echo "FAIL: chdman did not receive a unique destination-local temporary path: $tmp_path" >&2
+ exit 1
+}
+
+kill -TERM "$script_pid"
+set +e
+wait "$script_pid" 2>/dev/null
+interrupt_status=$?
+set -e
+[[ $interrupt_status -eq 130 ]] || {
+ echo "FAIL: interruption returned $interrupt_status, expected 130" >&2
+ exit 1
+}
+sleep 2.2
+assert_no_temporary_chds
+[[ ! -e "$FIX/Interrupted Game.chd" ]] || {
+ echo "FAIL: interrupted conversion produced a final CHD" >&2
+ exit 1
+}
+
+echo "PASS: failed and interrupted conversions clean unique temporary CHDs"
diff --git a/tests/test_transactional_source_deletion.sh b/tests/test_transactional_source_deletion.sh
new file mode 100644
index 0000000..3ec5b2e
--- /dev/null
+++ b/tests/test_transactional_source_deletion.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SCRIPT="${SCRIPT:-$REPO_ROOT/chdtool.sh}"
+FIX="$(mktemp -d)"
+trap 'rm -rf "$FIX"' EXIT
+
+export PATH="$REPO_ROOT/tests/bin:$PATH"
+export PROGRESS_STYLE=none
+export LOG_DEST=console
+export LOG_LEVEL_THRESHOLD=ERROR
+export CHDMAN_FAIL_INPUT="Transactional Game (Disc 2).iso"
+
+ARCHIVE="$FIX/transactional-game.zip"
+(
+ cd "$FIX"
+ : > "Transactional Game (Disc 1).iso"
+ : > "Transactional Game (Disc 2).iso"
+ zip -q "$(basename "$ARCHIVE")" \
+ "Transactional Game (Disc 1).iso" \
+ "Transactional Game (Disc 2).iso"
+ rm -f "Transactional Game (Disc 1).iso" "Transactional Game (Disc 2).iso"
+)
+
+set +e
+bash "$SCRIPT" "$FIX"
+status=$?
+set -e
+[[ $status -eq 2 ]] || { echo "FAIL: partial conversion returned $status, expected 2" >&2; exit 1; }
+
+if [[ ! -f "$ARCHIVE" ]]; then
+ echo "FAIL: source archive was deleted after a partial conversion" >&2
+ exit 1
+fi
+
+if [[ -f "$FIX/Transactional Game.m3u" ]]; then
+ echo "FAIL: M3U was generated for an incomplete CHD set" >&2
+ exit 1
+fi
+
+if [[ ! -f "$FIX/Transactional Game (Disc 1).chd" ]]; then
+ echo "FAIL: successfully verified Disc 1 CHD was not finalised" >&2
+ exit 1
+fi
+
+if [[ -f "$FIX/Transactional Game (Disc 2).chd" ]]; then
+ echo "FAIL: failed Disc 2 unexpectedly produced a final CHD" >&2
+ exit 1
+fi
+
+echo "PASS: partial archive conversion retains source and omits M3U"