emmercm/igir

View on GitHub
src/types/files/archives/chd/chdBinCueParser.ts

Summary

Maintainability
A
1 hr
Test Coverage
import fs from 'node:fs';
import path from 'node:path';
import util from 'node:util';

import { File as CueFile, parse, Track, TrackDataType } from '@gplane/cue';
import chdman from 'chdman';

import Temp from '../../../../globals/temp.js';
import FsPoly from '../../../../polyfill/fsPoly.js';
import ExpectedError from '../../../expectedError.js';
import FileChecksums, { ChecksumBitmask } from '../../fileChecksums.js';
import Archive from '../archive.js';
import ArchiveEntry from '../archiveEntry.js';

/**
 * https://github.com/putnam/binmerge
 */
export default class ChdBinCueParser {
  public static async getArchiveEntriesBinCue<T extends Archive>(
    archive: T,
    checksumBitmask: number,
  ): Promise<ArchiveEntry<T>[]> {
    const tempFile = await FsPoly.mktemp(
      path.join(Temp.getTempDir(), path.basename(archive.getFilePath())),
    );

    const tempDir = path.dirname(tempFile);
    if (!(await FsPoly.exists(tempDir))) {
      await FsPoly.mkdir(tempDir, { recursive: true });
    }

    const cueFile = `${tempFile}.cue`;
    const binFile = `${tempFile}.bin`;

    try {
      await chdman.extractCd({
        inputFilename: archive.getFilePath(),
        outputFilename: cueFile,
        outputBinFilename: binFile,
      });
      return await this.parseCue(archive, cueFile, binFile, checksumBitmask);
    } finally {
      await FsPoly.rm(cueFile, { force: true });
      await FsPoly.rm(binFile, { force: true });
    }
  }

  private static async parseCue<T extends Archive>(
    archive: T,
    cueFilePath: string,
    binFilePath: string,
    checksumBitmask: number,
  ): Promise<ArchiveEntry<T>[]> {
    const cueData = await util.promisify(fs.readFile)(cueFilePath);
    const cueSheet = parse(cueData.toString(), {
      fatal: true,
    }).sheet;

    const binFiles = (
      await Promise.all(
        cueSheet.files.flatMap(async (file) =>
          this.parseCueFile(archive, file, binFilePath, checksumBitmask),
        ),
      )
    ).flat();

    const cueFile = await ArchiveEntry.entryOf({
      archive,
      entryPath: `${path.parse(archive.getFilePath()).name}.cue`,
      // Junk size and checksums because we don't know what it should be
      size: 0,
      crc32: checksumBitmask & ChecksumBitmask.CRC32 ? 'x'.repeat(8) : undefined,
      md5: checksumBitmask & ChecksumBitmask.MD5 ? 'x'.repeat(32) : undefined,
      sha1: checksumBitmask & ChecksumBitmask.SHA1 ? 'x'.repeat(40) : undefined,
      sha256: checksumBitmask & ChecksumBitmask.SHA256 ? 'x'.repeat(64) : undefined,
    });

    return [cueFile, ...binFiles];
  }

  private static async parseCueFile<T extends Archive>(
    archive: T,
    file: CueFile,
    binFilePath: string,
    checksumBitmask: number,
  ): Promise<ArchiveEntry<T>[]> {
    // Determine the global block size from the first track in the file
    const filePath = path.join(path.dirname(binFilePath), file.name);
    const fileSize = await FsPoly.size(filePath);
    const firstTrack = file.tracks.at(0);
    if (!firstTrack) {
      return [];
    }
    const globalBlockSize = ChdBinCueParser.parseCueTrackBlockSize(firstTrack);
    let nextItemTimeOffset = Math.floor(fileSize / globalBlockSize);

    const { name: archiveName } = path.parse(archive.getFilePath());
    return (
      await Promise.all(
        file.tracks
          .reverse()
          .flatMap(async (track) => {
            const firstIndex = track.indexes.at(0);
            if (!firstIndex) {
              return undefined;
            }

            const [minutes, seconds, fields] = firstIndex.startingTime;
            const startingTimeOffset = fields + seconds * 75 + minutes * 60 * 75;
            const sectors = nextItemTimeOffset - startingTimeOffset;
            nextItemTimeOffset = startingTimeOffset;
            const trackOffset = startingTimeOffset * globalBlockSize;
            const trackSize = sectors * globalBlockSize;

            const checksums = await FileChecksums.hashFile(
              binFilePath,
              checksumBitmask,
              trackOffset,
              trackOffset + trackSize - 1,
            );

            return ArchiveEntry.entryOf(
              {
                archive,
                entryPath: `${archiveName} (Track ${track.trackNumber}).bin|${trackSize}@${trackOffset}`,
                size: trackSize,
                ...checksums,
              },
              checksumBitmask,
            );
          })
          .reverse(),
      )
    ).filter((entry) => entry !== undefined);
  }

  private static parseCueTrackBlockSize(firstTrack: Track): number {
    switch (firstTrack.dataType) {
      case TrackDataType.Audio:
      case TrackDataType['Mode1/2352']:
      case TrackDataType['Mode2/2352']:
      case TrackDataType['Cdi/2352']:
        return 2352;
      case TrackDataType.Cdg:
        return 2448;
      case TrackDataType['Mode1/2048']:
        return 2048;
      case TrackDataType['Mode2/2336']:
      case TrackDataType['Cdi/2336']:
        return 2336;
      default:
        throw new ExpectedError(`unknown track type ${TrackDataType[firstTrack.dataType]}`);
    }
  }
}