//SPDX-License-Identifier: GPL-3.0 /* * gogarchiver-ng, an archival tool for GOG.com * Copyright (C) 2024 Marko Zajc * * This program is free software: you can redistribute it and/or modify it under the * terms of the GNU General Public License as published by the Free Software * Foundation, version 3. * * This program is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A * PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with this * program. If not, see . */ package zajc.gogarchiver; import static java.lang.Long.MAX_VALUE; import static java.lang.Math.max; import static java.lang.System.*; import static java.nio.file.Files.createDirectories; import static java.util.Objects.requireNonNullElse; import static java.util.concurrent.Executors.newFixedThreadPool; import static java.util.concurrent.TimeUnit.NANOSECONDS; import static java.util.stream.Collectors.toUnmodifiableSet; import static java.util.stream.Stream.concat; import static me.tongfei.progressbar.ProgressBarStyle.*; import static picocli.CommandLine.Help.Ansi.OFF; import static zajc.gogarchiver.util.Utilities.*; import java.io.IOException; import java.util.*; import java.util.concurrent.*; import java.util.stream.Stream; import javax.annotation.*; import org.eu.zajc.ef.runnable.except.all.AERunnable; import me.tongfei.progressbar.*; import picocli.CommandLine; import picocli.CommandLine.*; import sun.misc.Signal; // NOSONAR it's just quality of life import zajc.gogarchiver.api.*; import zajc.gogarchiver.exception.NotLoggedInException; @Command(name = "gogarchiver", description = "an archival tool for GOG.com", version = "gogarchiver 1.0", mixinStandardHelpOptions = true, sortSynopsis = false, sortOptions = false) public class Main implements Callable { @Mixin private Arguments arguments; private void run() throws Exception { createDirectories(this.arguments.getOutputPath()); var downloads = getDownloadList(); if (downloads.isEmpty()) { if (!this.arguments.isQuiet()) out.println("\u001b[2KNothing to do"); } else { executeDownloads(downloads); if (!this.arguments.isQuiet()) out.println("Done"); } } @SuppressWarnings({ "resource", "null" }) private void executeDownloads(@Nonnull List downloads) throws InterruptedException { var progressTitleWidth = downloads.stream().map(GameDownload::getProgressTitle).mapToInt(String::length).max().orElse(-1); Map progressBars; if (this.arguments.isQuiet()) { progressBars = null; } else { progressBars = new HashMap<>(); downloads.stream().forEachOrdered(d -> { progressBars.put(d, downloadProgress(d.getProgressTitle(), progressTitleWidth)); }); } var pool = newFixedThreadPool(this.arguments.getThreads()); downloads.stream().forEachOrdered(d -> { startDownload(d, pool, progressBars); }); pool.shutdown(); pool.awaitTermination(MAX_VALUE, NANOSECONDS); } @SuppressWarnings({ "null", "resource" }) private void startDownload(@Nonnull GameDownload download, @Nonnull ExecutorService service, @Nullable Map progressBars) { service.submit((AERunnable) () -> { var progress = progressBars == null ? null : progressBars.get(download); download.downloadTo(this.arguments.getOutputPath(), progress); if (progress != null) { progress.stepTo(progress.getMax()); progress.refresh(); progress.pause(); } }); } @Nonnull @SuppressWarnings({ "null", "resource" }) public List getDownloadList() throws IOException, NotLoggedInException { ForkJoinPool pool = null; try (var p = this.arguments.isQuiet() ? null : createGameLoadingProgress()) { if (p != null) p.setExtraMessage("Loading user library"); var ids = this.arguments.getGameIds(); if (p != null) p.maxHint(ids.size()); var user = this.arguments.getUser(); pool = new ForkJoinPool(ids.size() + 1); // metadata requests take a while so it doesn't hurt to parallelize var games = pool.submit(() -> { // this is a hack to increase parallelStream()'s parallelism return ids.parallelStream().map(user::resolveGame).filter(Objects::nonNull).peek(g -> { // NOSONAR if (p != null) { p.setExtraMessage(g.getTitle()); p.step(); } }).collect(toUnmodifiableSet()); }).join(); if (p != null) { p.stepTo(ids.size()); p.setExtraMessage("Processing games"); } return createDownloadList(games, pool); } finally { if (pool != null) pool.shutdown(); if (!this.arguments.isQuiet()) cursorUp(); } } @Nonnull @SuppressWarnings("null") private List createDownloadList(@Nonnull Set games, @Nonnull ForkJoinPool pool) { var types = this.arguments.getTypes(); var platforms = this.arguments.getPlatforms(); var output = this.arguments.getOutputPath(); return pool.submit(() -> { return games.parallelStream().flatMap(g -> concat(Stream.of(g), g.getDlcs().stream())).filter(g -> { if (g instanceof GameDlc dlc && !this.arguments.downloadDlcs()) { verbose("Downloading DLCs is disabled - skipping DLC @|bold %s|@ of game @|bold %s|@", dlc.getTitle(), dlc.getParent().getTitle()); return false; } else { return true; } }).flatMap(g -> g.getDownloads().stream()).filter(d -> { if (!platforms.contains(d.platform())) { verbose("Downloading for @|bold %s|@ is disabled - not downloading @|bold %s|@", d.platform().toString().toLowerCase(), d.getProgressTitle()); return false; } else if (!types.contains(d.type())) { verbose("Downloading types of @|bold %s|@ is disabled - not downloading @|bold %s|@", d.type().toString().toLowerCase(), d.getProgressTitle()); return false; } else if (output.resolve(d.path()).toFile().exists()) { verbose("Not downloading @|bold %s|@ because it is already downloaded", d.type().toString().toLowerCase(), d.getProgressTitle()); return false; } else { return true; } }) .sorted(Comparator.comparing(d -> d.game().getTitle()) .thenComparing(GameDownload::platform) .thenComparing(d -> requireNonNullElse(d.version(), "")) .thenComparing(GameDownload::type) .thenComparingInt(GameDownload::part)) .toList(); }).join(); } @Nonnull @SuppressWarnings("null") public ProgressBar downloadProgress(@Nonnull String title, int titleMinWidth) { return new ProgressBarBuilder().setUpdateIntervalMillis(250) .setTaskName(title + ".".repeat(max(0, titleMinWidth - title.length()))) .setStyle(this.arguments.getColorMode() == OFF ? UNICODE_BLOCK : COLORFUL_UNICODE_BLOCK) .setInitialMax(1) .setUnit(" MiB", 1024L * 1024L) .build(); } @Nonnull @SuppressWarnings("null") private ProgressBar createGameLoadingProgress() { return new ProgressBarBuilder().setUpdateIntervalMillis(250) .setTaskName("Loading games") .setStyle(this.arguments.getColorMode() == OFF ? UNICODE_BLOCK : COLORFUL_UNICODE_BLOCK) .setInitialMax(-1) .continuousUpdate() .clearDisplayOnFinish() .hideEta() .build(); } @Override public Integer call() throws Exception { setVerbose(this.arguments.isVerbose()); setColorMode(this.arguments.getColorMode()); try { run(); } catch (NotLoggedInException e) { println(""" @|bold,red Invalid token.|@ Find your token by logging into GOG in your browser, \ and copying the "gog-al" cookie from its developer tools."""); return 1; } return 0; } public static void main(String[] args) { Signal.handle(new Signal("INT"), s -> { out.println(); exit(0); }); exit(new CommandLine(new Main()).setUsageHelpAutoWidth(true) .setUsageHelpLongOptionsMaxWidth(50) .setCaseInsensitiveEnumValuesAllowed(true) .setOverwrittenOptionsAllowed(true) .execute(args)); } }