573in1/tools/buildCDImage.py

281 lines
7.7 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""ISO9660 CD-ROM image generator
A simple tool for building ISO9660 CD-ROM images with 2048-byte sectors, as used
by the PlayStation 1 and other machines, from a JSON configuration file
describing their contents. Unlike most other ISO9660 authoring tools, files and
directories are placed in the image in the same order they are specified in the
JSON file, allowing for customization of the disc's layout. Requires pycdlib to
be installed.
"""
__version__ = "0.1.0"
__author__ = "spicyjpeg"
import json, re, sys
from argparse import ArgumentParser, FileType, Namespace
from pathlib import Path
from typing import Any, BinaryIO, Iterable
from pycdlib.dr import DirectoryRecord
from pycdlib.inode import Inode
from pycdlib.pycdlib import PyCdlib
## Utilities
PATH_IGNORE_REGEX: re.Pattern = re.compile(r";[0-9]+$")
PATH_INVALID_REGEX: re.Pattern = re.compile(r"[^0-9A-Z_./]")
def normalizePath(path: str, isDirectory: bool = False) -> str:
path = PATH_IGNORE_REGEX.sub("", path.upper())
path = PATH_INVALID_REGEX.sub("_", path)
if not path.startswith("/"):
path = f"/{path}"
if isDirectory:
path = path.replace(".", "_")
else:
# The ISO9660 specification requires file names to always contain
# exactly one period (even if no extension is present) and be terminated
# with a version number.
numPeriods: int = path.count(".")
if numPeriods:
path = path.replace(".", "_", numPeriods - 1)
else:
path += "."
path += ";1"
return path
def restoreISOFileOrder(
iso: PyCdlib, isoEntries: Iterable[DirectoryRecord | Inode],
printLayout: bool = False
):
# By default, when calling force_consistency(), pycdlib allocates files in
# the same order as their respective directory records, which are in turn
# sorted alphabetically as required by the ISO9660 specification. We're
# going to "undo" this sorting by iterating through the provided array and
# reassigning an LBA to each file and directory manually to restore the
# intended order.
iso.force_consistency()
sectorLength: int = iso.logical_block_size
sectorOffset: int = \
iso.pvd.path_table_location_be + iso.pvd.path_table_num_extents
if printLayout:
sys.stderr.write("CD-ROM image layout:\n")
# Allocate the indices (arrays of directory records) for the root directory
# and each subfolder. Note that these will always be placed before any file
# data, regardless of where they are listed in the configuration file.
for entry in isoEntries:
names: list[str] = []
length: int = \
(entry.data_length + sectorLength - 1) // sectorLength
if isinstance(entry, DirectoryRecord):
entry.set_data_location(sectorOffset, 0)
names.append(entry.file_identifier().decode("ascii"))
else:
entry.set_extent_location(sectorOffset)
for record, _ in entry.linked_records:
if not isinstance(record, DirectoryRecord):
continue
record.set_data_location(sectorOffset, 0)
names.append(record.file_identifier().decode("ascii"))
if printLayout:
nameList: str = ", ".join(names)
sys.stderr.write(
f" [{sectorOffset:6d}-{sectorOffset + length - 1:6d}] "
f"{nameList}\n"
)
sectorOffset += length
if printLayout:
sys.stderr.write(f"Total image size: {sectorOffset} sectors\n")
def showProgress(part: int, total: int, _: Any):
if part >= total:
sys.stderr.write("\rPacking finished.\n")
else:
sys.stderr.write(f"\rPacking: {part / total * 100.0:5.1f}%")
class PaddingFile(BinaryIO):
mode: str = "rb"
def read(self, length: int) -> bytes:
return bytes(length)
## Main
def createParser() -> ArgumentParser:
parser = ArgumentParser(
description = \
"Parses a JSON file containing a list of ISO9660 identifiers, file "
"and directory entries and generates a CD-ROM image.",
add_help = False
)
group = parser.add_argument_group("Tool options")
group.add_argument(
"-h", "--help",
action = "help",
help = "Show this help message and exit"
)
group.add_argument(
"-q", "--quiet",
action = "store_true",
help = "Suppress all non-error output"
)
group = parser.add_argument_group("CD-ROM image options")
group.add_argument(
"-l", "--level",
type = lambda value: int(value, 0),
default = 1,
help = \
"Set ISO9660 interchange level and maximum file name length "
"(default 1)",
metavar = "1-4"
)
group.add_argument(
"-x", "--xa",
action = "store_true",
help = "Add CD-XA header and metadata tags"
)
group.add_argument(
"-S", "--system-area",
type = FileType("rb"),
help = \
"Insert specified file (in 2048-bytes-per-sector format, up to 32 "
"KB) into the image's system area",
metavar = "file"
)
group = parser.add_argument_group("File paths")
group.add_argument(
"-s", "--source-dir",
type = Path,
help = \
"Set path to directory containing source files (same directory as "
"configuration file by default)",
metavar = "dir"
)
group.add_argument(
"configFile",
type = FileType("rt"),
help = "Path to JSON configuration file",
)
group.add_argument(
"output",
type = FileType("wb"),
help = "Path to ISO9660 CD-ROM image to generate"
)
return parser
def main():
parser: ArgumentParser = createParser()
args: Namespace = parser.parse_args()
with args.configFile as _file:
configFile: dict[str, Any] = json.load(_file)
sourceDir: Path = \
args.source_dir or Path(_file.name).parent
iso: PyCdlib = PyCdlib()
paddingFile: PaddingFile = PaddingFile()
identifiers: dict[str, str] = configFile.get("identifiers", {})
iso.new(
interchange_level = args.level,
sys_ident = identifiers.get("system", ""),
vol_ident = identifiers.get("volume", ""),
vol_set_ident = identifiers.get("volumeSet", ""),
pub_ident_str = identifiers.get("publisher", ""),
preparer_ident_str = identifiers.get("dataPreparer", ""),
app_ident_str = identifiers.get("application", ""),
copyright_file = identifiers.get("copyright", ""),
abstract_file = identifiers.get("abstract", ""),
bibli_file = identifiers.get("bibliographic", ""),
joliet = None,
rock_ridge = None,
xa = args.xa,
udf = None
)
entryList: list[dict[str, Any]] = configFile["entries"]
isoEntries: list[DirectoryRecord | Inode] = [
iso.pvd.root_directory_record()
]
for entry in entryList:
match entry.get("type", "file").strip():
case "empty":
name: str = normalizePath(entry["name"])
iso.add_fp(
fp = paddingFile,
length = int(entry.get("size", 0)),
iso_path = name
)
#iso.set_hidden(iso_path = name)
isoEntries.append(iso.inodes[-1])
case "file":
iso.add_file(
filename = sourceDir / entry["source"],
iso_path = normalizePath(entry["name"])
)
isoEntries.append(iso.inodes[-1])
case "fileAlias":
iso.add_hard_link(
iso_old_path = normalizePath(entry["source"]),
iso_new_path = normalizePath(entry["name"])
)
case "directoryAlias":
iso.add_hard_link(
iso_old_path = normalizePath(entry["source"], True),
iso_new_path = normalizePath(entry["name"], True)
)
case "directory":
name: str = normalizePath(entry["name"], True)
iso.add_directory(iso_path = name)
isoEntries.append(iso.get_record(iso_path = name))
case _type:
raise KeyError(f"unsupported entry type '{_type}'")
restoreISOFileOrder(iso, isoEntries, not args.quiet)
with args.output as _file:
iso.write_fp(
outfp = _file,
progress_cb = None if args.quiet else showProgress
)
iso.close()
if args.system_area:
with args.system_area as inputFile:
_file.seek(0)
_file.write(inputFile.read(iso.logical_block_size * 16))
if __name__ == "__main__":
main()