emmercm/igir

View on GitHub
src/modules/candidates/candidatePreferer.ts

Summary

Maintainability
D
3 days
Test Coverage
import ProgressBar, { ProgressBarSymbol } from '../../console/progressBar.js';
import fsPoly from '../../polyfill/fsPoly.js';
import DAT from '../../types/dats/dat.js';
import Parent from '../../types/dats/parent.js';
import Options, { PreferRevision } from '../../types/options.js';
import ReleaseCandidate from '../../types/releaseCandidate.js';
import Module from '../module.js';

/**
 * Apply any specified preference options to the {@link ReleaseCandidate}s for each
 * {@link Parent}.
 */
export default class CandidatePreferer extends Module {
  private readonly options: Options;

  constructor(options: Options, progressBar: ProgressBar) {
    super(progressBar, CandidatePreferer.name);
    this.options = options;
  }

  /**
   * Prefer some candidates.
   */
  prefer(
    dat: DAT,
    parentsToCandidates: Map<Parent, ReleaseCandidate[]>,
  ): Map<Parent, ReleaseCandidate[]> {
    this.progressBar.logTrace(`${dat.getNameShort()}: preferring candidates`);

    if (parentsToCandidates.size === 0) {
      this.progressBar.logTrace(`${dat.getNameShort()}: no parents, so no candidates to prefer`);
      return parentsToCandidates;
    }

    if (!this.options.getSingle()) {
      this.progressBar.logTrace(
        `${dat.getNameShort()}: not running in single/1G1R mode, not preferring candidates`,
      );
      return parentsToCandidates;
    }

    // Return early if there aren't any candidates
    const totalReleaseCandidates = [...parentsToCandidates.values()].reduce(
      (sum, rcs) => sum + rcs.length,
      0,
    );
    if (!totalReleaseCandidates) {
      this.progressBar.logTrace(`${dat.getNameShort()}: no parent has candidates`);
      return parentsToCandidates;
    }

    this.progressBar.setSymbol(ProgressBarSymbol.CANDIDATE_FILTERING);
    this.progressBar.reset(parentsToCandidates.size);

    const output = this.sortAndFilter(dat, parentsToCandidates);

    const size = [...output.values()]
      .flat()
      .flatMap((releaseCandidate) => releaseCandidate.getRomsWithFiles())
      .reduce((sum, romWithFiles) => sum + romWithFiles.getRom().getSize(), 0);
    const filteredCandidates = [...output.values()].reduce((sum, rc) => sum + rc.length, 0);
    this.progressBar.logTrace(
      `${dat.getNameShort()}: filtered to ${fsPoly.sizeReadable(size)} of ${filteredCandidates.toLocaleString()} candidate${filteredCandidates !== 1 ? 's' : ''} for ${output.size.toLocaleString()} parent${output.size !== 1 ? 's' : ''}`,
    );

    this.progressBar.logTrace(`${dat.getNameShort()}: done preferring candidates`);
    return output;
  }

  private sortAndFilter(
    dat: DAT,
    parentsToCandidates: Map<Parent, ReleaseCandidate[]>,
  ): Map<Parent, ReleaseCandidate[]> {
    const output = new Map<Parent, ReleaseCandidate[]>();

    for (let i = 0; i < [...parentsToCandidates.entries()].length; i += 1) {
      const [parent, releaseCandidates] = [...parentsToCandidates.entries()][i];
      this.progressBar.incrementProgress();
      if (releaseCandidates.length > 1) {
        // Reduce log spam by only logging parents that can be changed
        this.progressBar.logTrace(
          `${dat.getNameShort()}: ${parent.getName()} (parent): ${releaseCandidates.length.toLocaleString()} candidate${releaseCandidates.length !== 1 ? 's' : ''} before filtering`,
        );
      }

      const preferredReleaseCandidate = releaseCandidates
        .sort((a, b) => this.sort(a, b))
        .find(() => true);
      if (preferredReleaseCandidate) {
        this.progressBar.logTrace(
          `${dat.getNameShort()}: ${parent.getName()}: preferred ${preferredReleaseCandidate.getName()}`,
        );
        output.set(parent, [preferredReleaseCandidate]);
      } else {
        // The parent didn't have any candidates
        output.set(parent, []);
      }

      this.progressBar.incrementDone();
    }

    return output;
  }

  /**
   *******************
   *
   *     Sorting     *
   *
   *******************
   */

  private sort(a: ReleaseCandidate, b: ReleaseCandidate): number {
    return (
      this.preferGameRegexSort(a, b) ||
      this.preferRomRegexSort(a, b) ||
      this.preferVerifiedSort(a, b) ||
      this.preferGoodSort(a, b) ||
      this.preferLanguagesSort(a, b) ||
      this.preferRegionsSort(a, b) ||
      this.preferRevisionSort(a, b) ||
      this.preferRetailSort(a, b) ||
      this.preferParentSort(a, b)
    );
  }

  private preferGameRegexSort(a: ReleaseCandidate, b: ReleaseCandidate): number {
    const gameRegex = this.options.getPreferGameRegex();
    if (gameRegex === undefined || gameRegex.length === 0) {
      return 0;
    }

    const aMatched = gameRegex.some((regex) => regex.test(a.getGame().getName())) ? 0 : 1;
    const bMatched = gameRegex.some((regex) => regex.test(b.getGame().getName())) ? 0 : 1;
    return aMatched - bMatched;
  }

  private preferRomRegexSort(a: ReleaseCandidate, b: ReleaseCandidate): number {
    const romRegex = this.options.getPreferRomRegex();
    if (romRegex === undefined || romRegex.length === 0) {
      return 0;
    }

    const aMatched = romRegex.some((regex) =>
      a
        .getGame()
        .getRoms()
        .some((rom) => regex.test(rom.getName())),
    )
      ? 0
      : 1;
    const bMatched = romRegex.some((regex) =>
      b
        .getGame()
        .getRoms()
        .some((rom) => regex.test(rom.getName())),
    )
      ? 0
      : 1;
    return aMatched - bMatched;
  }

  private preferVerifiedSort(a: ReleaseCandidate, b: ReleaseCandidate): number {
    if (!this.options.getPreferVerified()) {
      return 0;
    }
    return (a.getGame().isVerified() ? 0 : 1) - (b.getGame().isVerified() ? 0 : 1);
  }

  private preferGoodSort(a: ReleaseCandidate, b: ReleaseCandidate): number {
    if (!this.options.getPreferGood()) {
      return 0;
    }
    return (b.getGame().isBad() ? 0 : 1) - (a.getGame().isBad() ? 0 : 1);
  }

  private preferLanguagesSort(a: ReleaseCandidate, b: ReleaseCandidate): number {
    const preferLanguages = this.options.getPreferLanguages();
    if (preferLanguages.length === 0) {
      return 0;
    }

    const aLangs = new Set(a.getLanguages());
    const bLangs = new Set(b.getLanguages());
    for (const preferredLang of preferLanguages) {
      if (aLangs.has(preferredLang) && !bLangs.has(preferredLang)) {
        return -1;
      }
      if (!aLangs.has(preferredLang) && bLangs.has(preferredLang)) {
        return 1;
      }
    }

    return 0;
  }

  private preferRegionsSort(a: ReleaseCandidate, b: ReleaseCandidate): number {
    if (this.options.getPreferRegions().length === 0) {
      return 0;
    }
    return this.preferRegionSortValue(a) - this.preferRegionSortValue(b);
  }

  private preferRegionSortValue(releaseCandidate: ReleaseCandidate): number {
    const region = releaseCandidate.getRegion();
    if (!region) {
      return Number.MAX_SAFE_INTEGER;
    }

    const regionIdx = this.options.getPreferRegions().indexOf(region);
    return regionIdx !== -1 ? regionIdx : Number.MAX_SAFE_INTEGER;
  }

  private preferRevisionSort(a: ReleaseCandidate, b: ReleaseCandidate): number {
    if (this.options.getPreferRevision() === PreferRevision.NEWER) {
      return b.getGame().getRevision() - a.getGame().getRevision();
    }
    if (this.options.getPreferRevision() === PreferRevision.OLDER) {
      return a.getGame().getRevision() - b.getGame().getRevision();
    }
    return 0;
  }

  private preferRetailSort(a: ReleaseCandidate, b: ReleaseCandidate): number {
    if (!this.options.getPreferRetail()) {
      return 0;
    }
    return (a.getGame().isRetail() ? 0 : 1) - (b.getGame().isRetail() ? 0 : 1);
  }

  private preferParentSort(a: ReleaseCandidate, b: ReleaseCandidate): number {
    if (!this.options.getPreferParent()) {
      return 0;
    }
    return (a.getGame().isParent() ? 0 : 1) - (b.getGame().isParent() ? 0 : 1);
  }
}