diff options
author | Marko Zajc <marko@zajc.eu.org> | 2024-03-19 01:45:53 +0100 |
---|---|---|
committer | Marko Zajc <marko@zajc.eu.org> | 2024-03-19 02:21:27 +0100 |
commit | 54cb615a682e1a5a886ec8c93fca8cfe8c1a8bc3 (patch) | |
tree | aa3746a058991d4bfb438390bfb104b130fc940b /src/main/java |
Diffstat (limited to 'src/main/java')
-rw-r--r-- | src/main/java/zajc/gogarchiver/Arguments.java | 206 | ||||
-rw-r--r-- | src/main/java/zajc/gogarchiver/Main.java | 246 | ||||
-rw-r--r-- | src/main/java/zajc/gogarchiver/api/Game.java | 103 | ||||
-rw-r--r-- | src/main/java/zajc/gogarchiver/api/GameDlc.java | 69 | ||||
-rw-r--r-- | src/main/java/zajc/gogarchiver/api/GameDownload.java | 149 | ||||
-rw-r--r-- | src/main/java/zajc/gogarchiver/api/User.java | 168 | ||||
-rw-r--r-- | src/main/java/zajc/gogarchiver/exception/NotLoggedInException.java | 25 | ||||
-rw-r--r-- | src/main/java/zajc/gogarchiver/util/LazyValue.java | 42 | ||||
-rw-r--r-- | src/main/java/zajc/gogarchiver/util/Utilities.java | 80 |
9 files changed, 1088 insertions, 0 deletions
diff --git a/src/main/java/zajc/gogarchiver/Arguments.java b/src/main/java/zajc/gogarchiver/Arguments.java new file mode 100644 index 0000000..cd24ffa --- /dev/null +++ b/src/main/java/zajc/gogarchiver/Arguments.java | |||
@@ -0,0 +1,206 @@ | |||
1 | //SPDX-License-Identifier: GPL-3.0 | ||
2 | /* | ||
3 | * gogarchiver-ng, an archival tool for GOG.com | ||
4 | * Copyright (C) 2024 Marko Zajc | ||
5 | * | ||
6 | * This program is free software: you can redistribute it and/or modify it under the | ||
7 | * terms of the GNU General Public License as published by the Free Software | ||
8 | * Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, but WITHOUT ANY | ||
11 | * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A | ||
12 | * PARTICULAR PURPOSE. See the GNU General Public License for more details. | ||
13 | * | ||
14 | * You should have received a copy of the GNU General Public License along with this | ||
15 | * program. If not, see <https://www.gnu.org/licenses/>. | ||
16 | */ | ||
17 | package zajc.gogarchiver; | ||
18 | |||
19 | import static java.lang.Runtime.getRuntime; | ||
20 | import static java.util.Collections.unmodifiableSet; | ||
21 | import static java.util.stream.Collectors.toUnmodifiableSet; | ||
22 | import static picocli.CommandLine.Help.Ansi.AUTO; | ||
23 | import static picocli.CommandLine.Help.Visibility.ALWAYS; | ||
24 | import static zajc.gogarchiver.api.GameDownload.Type.*; | ||
25 | |||
26 | import java.io.IOException; | ||
27 | import java.nio.file.*; | ||
28 | import java.util.*; | ||
29 | import java.util.function.Predicate; | ||
30 | |||
31 | import javax.annotation.Nonnull; | ||
32 | |||
33 | import org.eu.zajc.ef.supplier.except.all.AESupplier; | ||
34 | |||
35 | import picocli.CommandLine.*; | ||
36 | import picocli.CommandLine.Help.Ansi; | ||
37 | import zajc.gogarchiver.api.*; | ||
38 | import zajc.gogarchiver.api.GameDownload.Platform; | ||
39 | import zajc.gogarchiver.exception.NotLoggedInException; | ||
40 | import zajc.gogarchiver.util.LazyValue; | ||
41 | |||
42 | public class Arguments { | ||
43 | |||
44 | private final LazyValue<User> user = new LazyValue<>(); | ||
45 | |||
46 | @ArgGroup(exclusive = true, multiplicity = "1") private Token token; | ||
47 | |||
48 | private static class Token { | ||
49 | |||
50 | @Option(names = { "-k", "--token" }, description = "GOG token, which can be extracted from the gog-al cookie", | ||
51 | paramLabel = "TOKEN") private String tokenString; | ||
52 | @Option(names = { "-K", "--token-file" }, description = "read GOG token from a file", | ||
53 | paramLabel = "PATH") private Path tokenFile; | ||
54 | |||
55 | @Nonnull | ||
56 | @SuppressWarnings("null") | ||
57 | public String getTokenString() throws IOException { | ||
58 | var token = this.tokenString; | ||
59 | if (token == null) | ||
60 | token = Files.readString(this.tokenFile); | ||
61 | return token.strip(); | ||
62 | } | ||
63 | |||
64 | } | ||
65 | |||
66 | @Option(names = { "-o", "--output" }, required = true, description = "directory to write downloaded games to", | ||
67 | paramLabel = "PATH") private Path output; | ||
68 | @Option(names = { "-t", "--threads" }, description = "number of download threads", paramLabel = "THREADS", | ||
69 | showDefaultValue = ALWAYS) private int threads = getRuntime().availableProcessors(); | ||
70 | @Option(names = { "-q", "--quiet" }, description = "disable progress bars") private boolean quiet = false; | ||
71 | @Option(names = { "-c", "--color" }, description = "control output color. Supported are auto, on, off", | ||
72 | paramLabel = "MODE") private Ansi color = AUTO; | ||
73 | |||
74 | @ArgGroup(validate = false, heading = "%nFilter options%n") private Filters filters = new Filters(); | ||
75 | |||
76 | private static class Filters { | ||
77 | |||
78 | @Option(names = { "--no-installers" }, description = "download installers", negatable = true, | ||
79 | showDefaultValue = ALWAYS) private boolean installers = true; | ||
80 | @Option(names = { "--no-patches" }, description = "download version patches", negatable = true, | ||
81 | showDefaultValue = ALWAYS) private boolean patches = true; | ||
82 | @Option(names = { "--no-dlcs" }, description = "download available DLCs", negatable = true, | ||
83 | showDefaultValue = ALWAYS) private boolean dlcs = true; | ||
84 | |||
85 | @ArgGroup(exclusive = true) private GameIds gameIds = new GameIds(); | ||
86 | |||
87 | private static class GameIds { | ||
88 | |||
89 | @Option(names = { "-i", "--include-game" }, description = """ | ||
90 | only the listed game IDs will be downloaded. Game IDs can be obtained from https://www.gogdb.org/""", | ||
91 | paramLabel = "ID", split = ",") private Set<String> included; | ||
92 | @Option(names = { "-e", "--exclude-game" }, description = """ | ||
93 | all games owned by the account except those listed will be downloaded""", paramLabel = "ID", | ||
94 | split = ",") private Set<String> excluded; | ||
95 | |||
96 | @Nonnull | ||
97 | @SuppressWarnings("null") | ||
98 | public Set<String> getGameIds(@Nonnull User user) { | ||
99 | if (this.included != null) | ||
100 | return unmodifiableSet(this.included); | ||
101 | |||
102 | var library = user.getLibraryIds(); | ||
103 | |||
104 | if (this.excluded == null) | ||
105 | return library; | ||
106 | else | ||
107 | return library.stream().filter(Predicate.not(this.excluded::contains)).collect(toUnmodifiableSet()); | ||
108 | } | ||
109 | |||
110 | } | ||
111 | |||
112 | @ArgGroup(exclusive = true) private Platforms platforms = new Platforms(); | ||
113 | |||
114 | private static class Platforms { | ||
115 | |||
116 | @Option(names = { "--include-platform" }, description = """ | ||
117 | platforms to download for. Supported are linux, windows, mac""", paramLabel = "PLATFORM", | ||
118 | split = ",") private EnumSet<Platform> included; | ||
119 | @Option(names = { "--exclude-platform" }, description = """ | ||
120 | platforms to not download for""", paramLabel = "PLATFORM", | ||
121 | split = ",") private EnumSet<Platform> excluded; | ||
122 | |||
123 | @Nonnull | ||
124 | @SuppressWarnings("null") | ||
125 | public Set<Platform> getPlatforms() { | ||
126 | if (this.included != null) | ||
127 | return unmodifiableSet(this.included); | ||
128 | else if (this.excluded != null) | ||
129 | return unmodifiableSet(this.excluded); | ||
130 | else | ||
131 | return EnumSet.allOf(Platform.class); | ||
132 | } | ||
133 | |||
134 | } | ||
135 | |||
136 | } | ||
137 | |||
138 | @ArgGroup(validate = false, heading = "%nAdvanced options%n") private AdvancedOptions advanced = | ||
139 | new AdvancedOptions(); | ||
140 | |||
141 | private static class AdvancedOptions { | ||
142 | |||
143 | @Option(names = { "-v", "--verbose" }, description = "display verbose log messages") private boolean verbose = | ||
144 | false; | ||
145 | |||
146 | @Option(names = { "--unknown-types" }, description = "download unknown download types", negatable = true, | ||
147 | showDefaultValue = ALWAYS) private boolean unknown = false; | ||
148 | |||
149 | } | ||
150 | |||
151 | @Nonnull | ||
152 | @SuppressWarnings({ "unused", "null" }) | ||
153 | public User getUser() throws IOException, NotLoggedInException { | ||
154 | return this.user.get((AESupplier<User>) () -> new User(this.token.getTokenString())); | ||
155 | } | ||
156 | |||
157 | @Nonnull | ||
158 | public Set<String> getGameIds() throws IOException, NotLoggedInException { | ||
159 | return this.filters.gameIds.getGameIds(getUser()); | ||
160 | } | ||
161 | |||
162 | @Nonnull | ||
163 | @SuppressWarnings("null") | ||
164 | public Ansi getColorMode() { | ||
165 | return this.color; | ||
166 | } | ||
167 | |||
168 | public int getThreads() { | ||
169 | return this.threads; | ||
170 | } | ||
171 | |||
172 | public Path getOutputPath() { | ||
173 | return this.output; | ||
174 | } | ||
175 | |||
176 | public Set<Platform> getPlatforms() { | ||
177 | return this.filters.platforms.getPlatforms(); | ||
178 | } | ||
179 | |||
180 | public boolean downloadDlcs() { | ||
181 | return this.filters.dlcs; | ||
182 | } | ||
183 | |||
184 | public Set<GameDownload.Type> getTypes() { | ||
185 | var types = EnumSet.noneOf(GameDownload.Type.class); | ||
186 | if (this.filters.installers) | ||
187 | types.add(INSTALLER); | ||
188 | |||
189 | if (this.filters.patches) | ||
190 | types.add(PATCH); | ||
191 | |||
192 | if (this.advanced.unknown) | ||
193 | types.add(UNKNOWN); | ||
194 | |||
195 | return types; | ||
196 | } | ||
197 | |||
198 | public boolean isVerbose() { | ||
199 | return this.advanced.verbose; | ||
200 | } | ||
201 | |||
202 | public boolean isQuiet() { | ||
203 | return this.quiet; | ||
204 | } | ||
205 | |||
206 | } | ||
diff --git a/src/main/java/zajc/gogarchiver/Main.java b/src/main/java/zajc/gogarchiver/Main.java new file mode 100644 index 0000000..3cba37f --- /dev/null +++ b/src/main/java/zajc/gogarchiver/Main.java | |||
@@ -0,0 +1,246 @@ | |||
1 | //SPDX-License-Identifier: GPL-3.0 | ||
2 | /* | ||
3 | * gogarchiver-ng, an archival tool for GOG.com | ||
4 | * Copyright (C) 2024 Marko Zajc | ||
5 | * | ||
6 | * This program is free software: you can redistribute it and/or modify it under the | ||
7 | * terms of the GNU General Public License as published by the Free Software | ||
8 | * Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, but WITHOUT ANY | ||
11 | * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A | ||
12 | * PARTICULAR PURPOSE. See the GNU General Public License for more details. | ||
13 | * | ||
14 | * You should have received a copy of the GNU General Public License along with this | ||
15 | * program. If not, see <https://www.gnu.org/licenses/>. | ||
16 | */ | ||
17 | package zajc.gogarchiver; | ||
18 | |||
19 | import static java.lang.Long.MAX_VALUE; | ||
20 | import static java.lang.Math.max; | ||
21 | import static java.lang.System.*; | ||
22 | import static java.nio.file.Files.createDirectories; | ||
23 | import static java.util.Objects.requireNonNullElse; | ||
24 | import static java.util.concurrent.Executors.newFixedThreadPool; | ||
25 | import static java.util.concurrent.TimeUnit.NANOSECONDS; | ||
26 | import static java.util.stream.Collectors.toUnmodifiableSet; | ||
27 | import static java.util.stream.Stream.concat; | ||
28 | import static me.tongfei.progressbar.ProgressBarStyle.*; | ||
29 | import static picocli.CommandLine.Help.Ansi.OFF; | ||
30 | import static zajc.gogarchiver.util.Utilities.*; | ||
31 | |||
32 | import java.io.IOException; | ||
33 | import java.util.*; | ||
34 | import java.util.concurrent.*; | ||
35 | import java.util.stream.Stream; | ||
36 | |||
37 | import javax.annotation.*; | ||
38 | |||
39 | import org.eu.zajc.ef.runnable.except.all.AERunnable; | ||
40 | |||
41 | import me.tongfei.progressbar.*; | ||
42 | import picocli.CommandLine; | ||
43 | import picocli.CommandLine.*; | ||
44 | import sun.misc.Signal; // NOSONAR it's just quality of life | ||
45 | import zajc.gogarchiver.api.*; | ||
46 | import zajc.gogarchiver.exception.NotLoggedInException; | ||
47 | |||
48 | @Command(name = "gogarchiver", description = "an archival tool for GOG.com", version = "gogarchiver 1.0", | ||
49 | mixinStandardHelpOptions = true, sortSynopsis = false, sortOptions = false) | ||
50 | public class Main implements Callable<Integer> { | ||
51 | |||
52 | @Mixin private Arguments arguments; | ||
53 | |||
54 | private void run() throws Exception { | ||
55 | createDirectories(this.arguments.getOutputPath()); | ||
56 | var downloads = getDownloadList(); | ||
57 | if (downloads.isEmpty()) { | ||
58 | if (!this.arguments.isQuiet()) | ||
59 | out.println("\u001b[2KNothing to do"); | ||
60 | |||
61 | } else { | ||
62 | executeDownloads(downloads); | ||
63 | |||
64 | if (!this.arguments.isQuiet()) | ||
65 | out.println("Done"); | ||
66 | } | ||
67 | } | ||
68 | |||
69 | @SuppressWarnings({ "resource", "null" }) | ||
70 | private void executeDownloads(@Nonnull List<GameDownload> downloads) throws InterruptedException { | ||
71 | var progressTitleWidth = | ||
72 | downloads.stream().map(GameDownload::getProgressTitle).mapToInt(String::length).max().orElse(-1); | ||
73 | |||
74 | Map<GameDownload, ProgressBar> progressBars; | ||
75 | if (this.arguments.isQuiet()) { | ||
76 | progressBars = null; | ||
77 | |||
78 | } else { | ||
79 | progressBars = new HashMap<>(); | ||
80 | downloads.stream().forEachOrdered(d -> { | ||
81 | progressBars.put(d, downloadProgress(d.getProgressTitle(), progressTitleWidth)); | ||
82 | }); | ||
83 | } | ||
84 | |||
85 | var pool = newFixedThreadPool(this.arguments.getThreads()); | ||
86 | downloads.stream().forEachOrdered(d -> { | ||
87 | startDownload(d, pool, progressBars); | ||
88 | }); | ||
89 | |||
90 | pool.shutdown(); | ||
91 | pool.awaitTermination(MAX_VALUE, NANOSECONDS); | ||
92 | } | ||
93 | |||
94 | @SuppressWarnings({ "null", "resource" }) | ||
95 | private void startDownload(@Nonnull GameDownload download, @Nonnull ExecutorService service, | ||
96 | @Nullable Map<GameDownload, ProgressBar> progressBars) { | ||
97 | service.submit((AERunnable) () -> { | ||
98 | var progress = progressBars == null ? null : progressBars.get(download); | ||
99 | |||
100 | download.downloadTo(this.arguments.getOutputPath(), progress); | ||
101 | |||
102 | if (progress != null) { | ||
103 | progress.stepTo(progress.getMax()); | ||
104 | progress.refresh(); | ||
105 | progress.pause(); | ||
106 | } | ||
107 | }); | ||
108 | } | ||
109 | |||
110 | @Nonnull | ||
111 | @SuppressWarnings({ "null", "resource" }) | ||
112 | public List<GameDownload> getDownloadList() throws IOException, NotLoggedInException { | ||
113 | ForkJoinPool pool = null; | ||
114 | try (var p = this.arguments.isQuiet() ? null : createGameLoadingProgress()) { | ||
115 | if (p != null) | ||
116 | p.setExtraMessage("Loading user library"); | ||
117 | |||
118 | var ids = this.arguments.getGameIds(); | ||
119 | if (p != null) | ||
120 | p.maxHint(ids.size()); | ||
121 | |||
122 | var user = this.arguments.getUser(); | ||
123 | pool = new ForkJoinPool(ids.size() + 1); // metadata requests take a while so it doesn't hurt to parallelize | ||
124 | var games = pool.submit(() -> { // this is a hack to increase parallelStream()'s parallelism | ||
125 | return ids.parallelStream().map(user::resolveGame).filter(Objects::nonNull).peek(g -> { // NOSONAR | ||
126 | if (p != null) { | ||
127 | p.setExtraMessage(g.getTitle()); | ||
128 | p.step(); | ||
129 | } | ||
130 | }).collect(toUnmodifiableSet()); | ||
131 | }).join(); | ||
132 | |||
133 | if (p != null) { | ||
134 | p.stepTo(ids.size()); | ||
135 | p.setExtraMessage("Processing games"); | ||
136 | } | ||
137 | return createDownloadList(games, pool); | ||
138 | |||
139 | } finally { | ||
140 | if (pool != null) | ||
141 | pool.shutdown(); | ||
142 | if (!this.arguments.isQuiet()) | ||
143 | cursorUp(); | ||
144 | } | ||
145 | } | ||
146 | |||
147 | @Nonnull | ||
148 | @SuppressWarnings("null") | ||
149 | private List<GameDownload> createDownloadList(@Nonnull Set<Game> games, @Nonnull ForkJoinPool pool) { | ||
150 | var types = this.arguments.getTypes(); | ||
151 | var platforms = this.arguments.getPlatforms(); | ||
152 | var output = this.arguments.getOutputPath(); | ||
153 | |||
154 | return pool.submit(() -> { | ||
155 | return games.parallelStream().flatMap(g -> concat(Stream.of(g), g.getDlcs().stream())).filter(g -> { | ||
156 | if (g instanceof GameDlc dlc && !this.arguments.downloadDlcs()) { | ||
157 | verbose("Downloading DLCs is disabled - skipping DLC @|bold %s|@ of game @|bold %s|@", | ||
158 | dlc.getTitle(), dlc.getParent().getTitle()); | ||
159 | |||
160 | return false; | ||
161 | } else { | ||
162 | return true; | ||
163 | } | ||
164 | }).flatMap(g -> g.getDownloads().stream()).filter(d -> { | ||
165 | if (!platforms.contains(d.platform())) { | ||
166 | verbose("Downloading for @|bold %s|@ is disabled - not downloading @|bold %s|@", | ||
167 | d.platform().toString().toLowerCase(), d.getProgressTitle()); | ||
168 | return false; | ||
169 | |||
170 | } else if (!types.contains(d.type())) { | ||
171 | verbose("Downloading types of @|bold %s|@ is disabled - not downloading @|bold %s|@", | ||
172 | d.type().toString().toLowerCase(), d.getProgressTitle()); | ||
173 | return false; | ||
174 | |||
175 | } else if (output.resolve(d.path()).toFile().exists()) { | ||
176 | verbose("Not downloading @|bold %s|@ because it is already downloaded", | ||
177 | d.type().toString().toLowerCase(), d.getProgressTitle()); | ||
178 | return false; | ||
179 | |||
180 | } else { | ||
181 | return true; | ||
182 | } | ||
183 | }) | ||
184 | .sorted(Comparator.<GameDownload, String>comparing(d -> d.game().getTitle()) | ||
185 | .thenComparing(GameDownload::platform) | ||
186 | .thenComparing(d -> requireNonNullElse(d.version(), "")) | ||
187 | .thenComparing(GameDownload::type) | ||
188 | .thenComparingInt(GameDownload::part)) | ||
189 | .toList(); | ||
190 | }).join(); | ||
191 | } | ||
192 | |||
193 | @Nonnull | ||
194 | @SuppressWarnings("null") | ||
195 | public ProgressBar downloadProgress(@Nonnull String title, int titleMinWidth) { | ||
196 | return new ProgressBarBuilder().setUpdateIntervalMillis(250) | ||
197 | .setTaskName(title + ".".repeat(max(0, titleMinWidth - title.length()))) | ||
198 | .setStyle(this.arguments.getColorMode() == OFF ? UNICODE_BLOCK : COLORFUL_UNICODE_BLOCK) | ||
199 | .setInitialMax(1) | ||
200 | .setUnit(" MiB", 1024L * 1024L) | ||
201 | .build(); | ||
202 | } | ||
203 | |||
204 | @Nonnull | ||
205 | @SuppressWarnings("null") | ||
206 | private ProgressBar createGameLoadingProgress() { | ||
207 | return new ProgressBarBuilder().setUpdateIntervalMillis(250) | ||
208 | .setTaskName("Loading games") | ||
209 | .setStyle(this.arguments.getColorMode() == OFF ? UNICODE_BLOCK : COLORFUL_UNICODE_BLOCK) | ||
210 | .setInitialMax(-1) | ||
211 | .continuousUpdate() | ||
212 | .clearDisplayOnFinish() | ||
213 | .hideEta() | ||
214 | .build(); | ||
215 | } | ||
216 | |||
217 | @Override | ||
218 | public Integer call() throws Exception { | ||
219 | setVerbose(this.arguments.isVerbose()); | ||
220 | setColorMode(this.arguments.getColorMode()); | ||
221 | |||
222 | try { | ||
223 | run(); | ||
224 | } catch (NotLoggedInException e) { | ||
225 | println(""" | ||
226 | @|bold,red Invalid token.|@ Find your token by logging into GOG in your browser, \ | ||
227 | and copying the "gog-al" cookie from its developer tools."""); | ||
228 | return 1; | ||
229 | } | ||
230 | return 0; | ||
231 | } | ||
232 | |||
233 | public static void main(String[] args) { | ||
234 | Signal.handle(new Signal("INT"), s -> { | ||
235 | out.println(); | ||
236 | exit(0); | ||
237 | }); | ||
238 | |||
239 | exit(new CommandLine(new Main()).setUsageHelpAutoWidth(true) | ||
240 | .setUsageHelpLongOptionsMaxWidth(50) | ||
241 | .setCaseInsensitiveEnumValuesAllowed(true) | ||
242 | .setOverwrittenOptionsAllowed(true) | ||
243 | .execute(args)); | ||
244 | } | ||
245 | |||
246 | } | ||
diff --git a/src/main/java/zajc/gogarchiver/api/Game.java b/src/main/java/zajc/gogarchiver/api/Game.java new file mode 100644 index 0000000..5180a20 --- /dev/null +++ b/src/main/java/zajc/gogarchiver/api/Game.java | |||
@@ -0,0 +1,103 @@ | |||
1 | //SPDX-License-Identifier: GPL-3.0 | ||
2 | /* | ||
3 | * gogarchiver-ng, an archival tool for GOG.com | ||
4 | * Copyright (C) 2024 Marko Zajc | ||
5 | * | ||
6 | * This program is free software: you can redistribute it and/or modify it under the | ||
7 | * terms of the GNU General Public License as published by the Free Software | ||
8 | * Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, but WITHOUT ANY | ||
11 | * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A | ||
12 | * PARTICULAR PURPOSE. See the GNU General Public License for more details. | ||
13 | * | ||
14 | * You should have received a copy of the GNU General Public License along with this | ||
15 | * program. If not, see <https://www.gnu.org/licenses/>. | ||
16 | */ | ||
17 | package zajc.gogarchiver.api; | ||
18 | |||
19 | import static java.util.Objects.hash; | ||
20 | import static zajc.gogarchiver.util.Utilities.stream; | ||
21 | |||
22 | import java.util.*; | ||
23 | |||
24 | import javax.annotation.Nonnull; | ||
25 | |||
26 | import kong.unirest.json.*; | ||
27 | import zajc.gogarchiver.api.GameDownload.Platform; | ||
28 | |||
29 | public class Game { | ||
30 | |||
31 | @Nonnull private final User user; | ||
32 | @Nonnull private final String id; | ||
33 | @Nonnull private final String title; | ||
34 | @Nonnull private final List<GameDownload> downloads; | ||
35 | @Nonnull private final List<GameDlc> dlcs; | ||
36 | |||
37 | @SuppressWarnings("null") | ||
38 | protected Game(@Nonnull User user, @Nonnull String id, @Nonnull String title, @Nonnull JSONObject downloads, | ||
39 | @Nonnull JSONArray dlcs) { | ||
40 | this.user = user; | ||
41 | this.id = id; | ||
42 | this.title = title; | ||
43 | |||
44 | this.downloads = downloads.keySet().stream().flatMap(p -> { | ||
45 | var platform = Platform.valueOf(p.toUpperCase()); | ||
46 | return stream(downloads.getJSONArray(p)).map(JSONObject.class::cast) | ||
47 | .map(d -> GameDownload.fromJson(this, d, platform)); // NOSONAR | ||
48 | }).toList(); | ||
49 | |||
50 | this.dlcs = stream(dlcs).map(JSONObject.class::cast).map(j -> GameDlc.fromJson(this, j)).toList(); | ||
51 | } | ||
52 | |||
53 | @Nonnull | ||
54 | @SuppressWarnings("null") | ||
55 | public static Game fromJson(@Nonnull User user, @Nonnull JSONObject json, @Nonnull String id) { | ||
56 | var title = json.getString("title"); | ||
57 | var downloads = json.getJSONArray("downloads").getJSONArray(0).getJSONObject(1); | ||
58 | var dlcs = json.getJSONArray("dlcs"); | ||
59 | |||
60 | return new Game(user, id, title, downloads, dlcs); | ||
61 | } | ||
62 | |||
63 | @Nonnull | ||
64 | public User getUser() { | ||
65 | return this.user; | ||
66 | } | ||
67 | |||
68 | @Nonnull | ||
69 | public String getId() { | ||
70 | return this.id; | ||
71 | } | ||
72 | |||
73 | @Nonnull | ||
74 | public String getTitle() { | ||
75 | return this.title; | ||
76 | } | ||
77 | |||
78 | @Nonnull | ||
79 | public List<GameDownload> getDownloads() { | ||
80 | return this.downloads; | ||
81 | } | ||
82 | |||
83 | @Nonnull | ||
84 | public List<GameDlc> getDlcs() { | ||
85 | return this.dlcs; | ||
86 | } | ||
87 | |||
88 | @Override | ||
89 | public int hashCode() { | ||
90 | return hash(this.id, this.title); | ||
91 | } | ||
92 | |||
93 | @Override | ||
94 | public boolean equals(Object obj) { | ||
95 | if (this == obj) | ||
96 | return true; | ||
97 | if (obj instanceof Game other) | ||
98 | return Objects.equals(this.id, other.id) && Objects.equals(this.title, other.title); | ||
99 | else | ||
100 | return false; | ||
101 | } | ||
102 | |||
103 | } | ||
diff --git a/src/main/java/zajc/gogarchiver/api/GameDlc.java b/src/main/java/zajc/gogarchiver/api/GameDlc.java new file mode 100644 index 0000000..91a08ac --- /dev/null +++ b/src/main/java/zajc/gogarchiver/api/GameDlc.java | |||
@@ -0,0 +1,69 @@ | |||
1 | //SPDX-License-Identifier: GPL-3.0 | ||
2 | /* | ||
3 | * gogarchiver-ng, an archival tool for GOG.com | ||
4 | * Copyright (C) 2024 Marko Zajc | ||
5 | * | ||
6 | * This program is free software: you can redistribute it and/or modify it under the | ||
7 | * terms of the GNU General Public License as published by the Free Software | ||
8 | * Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, but WITHOUT ANY | ||
11 | * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A | ||
12 | * PARTICULAR PURPOSE. See the GNU General Public License for more details. | ||
13 | * | ||
14 | * You should have received a copy of the GNU General Public License along with this | ||
15 | * program. If not, see <https://www.gnu.org/licenses/>. | ||
16 | */ | ||
17 | package zajc.gogarchiver.api; | ||
18 | |||
19 | import java.util.Objects; | ||
20 | |||
21 | import javax.annotation.Nonnull; | ||
22 | |||
23 | import kong.unirest.json.*; | ||
24 | |||
25 | public class GameDlc extends Game { | ||
26 | |||
27 | @Nonnull private final Game parent; | ||
28 | |||
29 | private GameDlc(@Nonnull Game parent, @Nonnull String title, @Nonnull JSONObject downloads) { | ||
30 | super(parent.getUser(), parent.getId(), title, downloads, new JSONArray()); | ||
31 | |||
32 | this.parent = parent; | ||
33 | } | ||
34 | |||
35 | @Nonnull | ||
36 | @SuppressWarnings("null") | ||
37 | public static GameDlc fromJson(@Nonnull Game parent, @Nonnull JSONObject json) { | ||
38 | var title = json.getString("title"); | ||
39 | var downloads = json.getJSONArray("downloads").getJSONArray(0).getJSONObject(1); | ||
40 | |||
41 | return new GameDlc(parent, title, downloads); | ||
42 | } | ||
43 | |||
44 | @Nonnull | ||
45 | public Game getParent() { | ||
46 | return this.parent; | ||
47 | } | ||
48 | |||
49 | @Override | ||
50 | public int hashCode() { | ||
51 | final int prime = 31; | ||
52 | int result = super.hashCode(); | ||
53 | result = prime * result + Objects.hash(this.parent); | ||
54 | return result; | ||
55 | } | ||
56 | |||
57 | @Override | ||
58 | public boolean equals(Object obj) { | ||
59 | if (this == obj) | ||
60 | return true; | ||
61 | else if (!super.equals(obj)) | ||
62 | return false; | ||
63 | else if (obj instanceof GameDlc other) | ||
64 | return Objects.equals(this.parent, other.parent); | ||
65 | else | ||
66 | return false; | ||
67 | } | ||
68 | |||
69 | } | ||
diff --git a/src/main/java/zajc/gogarchiver/api/GameDownload.java b/src/main/java/zajc/gogarchiver/api/GameDownload.java new file mode 100644 index 0000000..61a1d41 --- /dev/null +++ b/src/main/java/zajc/gogarchiver/api/GameDownload.java | |||
@@ -0,0 +1,149 @@ | |||
1 | //SPDX-License-Identifier: GPL-3.0 | ||
2 | /* | ||
3 | * gogarchiver-ng, an archival tool for GOG.com | ||
4 | * Copyright (C) 2024 Marko Zajc | ||
5 | * | ||
6 | * This program is free software: you can redistribute it and/or modify it under the | ||
7 | * terms of the GNU General Public License as published by the Free Software | ||
8 | * Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, but WITHOUT ANY | ||
11 | * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A | ||
12 | * PARTICULAR PURPOSE. See the GNU General Public License for more details. | ||
13 | * | ||
14 | * You should have received a copy of the GNU General Public License along with this | ||
15 | * program. If not, see <https://www.gnu.org/licenses/>. | ||
16 | */ | ||
17 | package zajc.gogarchiver.api; | ||
18 | |||
19 | import static java.lang.Integer.parseInt; | ||
20 | import static java.lang.String.format; | ||
21 | import static java.nio.charset.StandardCharsets.UTF_8; | ||
22 | import static java.util.Objects.hash; | ||
23 | import static java.util.regex.Pattern.compile; | ||
24 | import static zajc.gogarchiver.api.GameDownload.Type.*; | ||
25 | import static zajc.gogarchiver.util.Utilities.warn; | ||
26 | |||
27 | import java.io.IOException; | ||
28 | import java.net.URLDecoder; | ||
29 | import java.nio.file.Path; | ||
30 | import java.util.Objects; | ||
31 | import java.util.regex.Pattern; | ||
32 | |||
33 | import javax.annotation.*; | ||
34 | |||
35 | import kong.unirest.json.JSONObject; | ||
36 | import me.tongfei.progressbar.ProgressBar; | ||
37 | import zajc.gogarchiver.util.LazyValue; | ||
38 | |||
39 | public record GameDownload(@Nonnull Game game, @Nonnull String originalUrl, @Nonnull LazyValue<String> resolvedUrl, | ||
40 | @Nonnull Platform platform, @Nullable String name, @Nullable String version, | ||
41 | @Nonnull Type type, int part) { | ||
42 | |||
43 | private static final Pattern TYPE_PATTERN = compile("\\d+(\\p{IsLatin}+)"); | ||
44 | private static final Pattern PART_PATTERN = compile("\\d+$"); | ||
45 | |||
46 | @Nonnull | ||
47 | public static GameDownload fromJson(@Nonnull Game game, @Nonnull JSONObject download, @Nonnull Platform platform) { | ||
48 | var url = "https://www.gog.com/" + download.getString("manualUrl").substring(1); | ||
49 | var name = download.optString("name"); | ||
50 | var version = download.optString("version"); | ||
51 | var type = parseType(url); | ||
52 | var part = parsePart(url); | ||
53 | |||
54 | return new GameDownload(game, url, new LazyValue<>(), platform, name, version, type, part); | ||
55 | } | ||
56 | |||
57 | @Nonnull | ||
58 | private static Type parseType(@Nonnull String url) { | ||
59 | var m = TYPE_PATTERN.matcher(url.substring(url.lastIndexOf('/') + 1)); | ||
60 | if (!m.find()) { | ||
61 | warn("Could not extract download type from the url: %s. Please report this to marko@zajc.eu.org.", url); | ||
62 | return UNKNOWN; | ||
63 | } | ||
64 | |||
65 | return switch (m.group(1)) { | ||
66 | case "installer" -> INSTALLER; | ||
67 | case "patch" -> PATCH; | ||
68 | default -> { | ||
69 | warn("Unknown download type: %s. Please report this to marko@zajc.eu.org.", m.group(1)); | ||
70 | yield UNKNOWN; | ||
71 | } | ||
72 | }; | ||
73 | } | ||
74 | |||
75 | private static int parsePart(@Nonnull String url) { | ||
76 | var m = PART_PATTERN.matcher(url); | ||
77 | if (!m.find()) { | ||
78 | warn("Could not extract part number from the url: %s. Please report this to marko@zajc.eu.org.", url); | ||
79 | return 0; | ||
80 | |||
81 | } else { | ||
82 | return parseInt(m.group()); | ||
83 | } | ||
84 | } | ||
85 | |||
86 | @SuppressWarnings("null") | ||
87 | public void downloadTo(@Nonnull Path outputDirectory, @Nullable ProgressBar monitor) throws IOException { | ||
88 | game().getUser().downloadTo(this, outputDirectory.resolve(path()), monitor); | ||
89 | } | ||
90 | |||
91 | @Nonnull | ||
92 | @SuppressWarnings("null") | ||
93 | public Path path() { | ||
94 | return Path.of(this.game.getTitle(), this.platform.toString().toLowerCase(), this.type.toString().toLowerCase(), | ||
95 | URLDecoder.decode(url().substring(url().lastIndexOf('/') + 1), UTF_8)); | ||
96 | } | ||
97 | |||
98 | @Nonnull | ||
99 | @SuppressWarnings("null") | ||
100 | public String url() { | ||
101 | return this.resolvedUrl.get(() -> game().getUser().resolveUrl(this)); | ||
102 | } | ||
103 | |||
104 | @Nonnull | ||
105 | @SuppressWarnings("null") | ||
106 | public String getProgressTitle() { | ||
107 | return format("%s (%s%s%s, %s)", game().getTitle(), this.part != 0 ? "part " + (part() + 1) + ", " : "", | ||
108 | version() != null ? "ver. " + version() + ", " : "", platform().toString().toLowerCase(), | ||
109 | type().toString().toLowerCase()); | ||
110 | } | ||
111 | |||
112 | @Override | ||
113 | public int hashCode() { | ||
114 | return hash(this.game, this.name, this.originalUrl, this.platform, this.type, this.version); | ||
115 | } | ||
116 | |||
117 | @Override | ||
118 | public boolean equals(Object obj) { | ||
119 | if (this == obj) | ||
120 | return true; | ||
121 | if (obj instanceof GameDownload other) | ||
122 | return Objects.equals(this.game, other.game) && Objects.equals(this.name, other.name) | ||
123 | && Objects.equals(this.originalUrl, other.originalUrl) && this.platform == other.platform | ||
124 | && this.type == other.type && Objects.equals(this.version, other.version); | ||
125 | else | ||
126 | return false; | ||
127 | } | ||
128 | |||
129 | public enum Platform { | ||
130 | |||
131 | LINUX, | ||
132 | WINDOWS, | ||
133 | MAC; | ||
134 | |||
135 | @Override | ||
136 | public String toString() { | ||
137 | return name().toLowerCase(); | ||
138 | } | ||
139 | } | ||
140 | |||
141 | public enum Type { | ||
142 | |||
143 | INSTALLER, | ||
144 | PATCH, | ||
145 | UNKNOWN; | ||
146 | |||
147 | } | ||
148 | |||
149 | } | ||
diff --git a/src/main/java/zajc/gogarchiver/api/User.java b/src/main/java/zajc/gogarchiver/api/User.java new file mode 100644 index 0000000..114cb26 --- /dev/null +++ b/src/main/java/zajc/gogarchiver/api/User.java | |||
@@ -0,0 +1,168 @@ | |||
1 | //SPDX-License-Identifier: GPL-3.0 | ||
2 | /* | ||
3 | * gogarchiver-ng, an archival tool for GOG.com | ||
4 | * Copyright (C) 2024 Marko Zajc | ||
5 | * | ||
6 | * This program is free software: you can redistribute it and/or modify it under the | ||
7 | * terms of the GNU General Public License as published by the Free Software | ||
8 | * Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, but WITHOUT ANY | ||
11 | * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A | ||
12 | * PARTICULAR PURPOSE. See the GNU General Public License for more details. | ||
13 | * | ||
14 | * You should have received a copy of the GNU General Public License along with this | ||
15 | * program. If not, see <https://www.gnu.org/licenses/>. | ||
16 | */ | ||
17 | package zajc.gogarchiver.api; | ||
18 | |||
19 | import static java.io.File.separatorChar; | ||
20 | import static java.nio.file.Files.createDirectories; | ||
21 | import static java.util.stream.Collectors.toUnmodifiableSet; | ||
22 | import static zajc.gogarchiver.api.GameDownload.Platform.LINUX; | ||
23 | import static zajc.gogarchiver.util.Utilities.*; | ||
24 | |||
25 | import java.io.*; | ||
26 | import java.nio.file.Path; | ||
27 | import java.util.*; | ||
28 | import java.util.concurrent.ConcurrentHashMap; | ||
29 | |||
30 | import javax.annotation.*; | ||
31 | |||
32 | import kong.unirest.*; | ||
33 | import kong.unirest.json.JSONObject; | ||
34 | import me.tongfei.progressbar.ProgressBar; | ||
35 | import zajc.gogarchiver.exception.NotLoggedInException; | ||
36 | import zajc.gogarchiver.util.LazyValue; | ||
37 | |||
38 | public class User { | ||
39 | |||
40 | private static final UnirestInstance UNIREST_NO_REDIRECT = new UnirestInstance(new Config().followRedirects(false)); | ||
41 | |||
42 | private static final String URL_USER = "https://www.gog.com/userData.json"; | ||
43 | private static final String URL_LIBRARY = "https://menu.gog.com/v1/account/licences"; | ||
44 | private static final String URL_GAME_DETAILS = "https://www.gog.com/account/gameDetails/%s.json"; | ||
45 | |||
46 | @Nonnull private final String token; | ||
47 | @Nonnull private final LazyValue<Set<String>> libraryIds = new LazyValue<>(); | ||
48 | @Nonnull private final LazyValue<JSONObject> userData = new LazyValue<>(); | ||
49 | @Nonnull private final Map<String, Game> games = new ConcurrentHashMap<>(); | ||
50 | |||
51 | public User(@Nonnull String token) throws NotLoggedInException { | ||
52 | this.token = token; | ||
53 | |||
54 | if (!isLoggedIn()) | ||
55 | throw new NotLoggedInException(); | ||
56 | } | ||
57 | |||
58 | private boolean isLoggedIn() { | ||
59 | return getUserData().getBoolean("isLoggedIn"); | ||
60 | } | ||
61 | |||
62 | @Nonnull | ||
63 | @SuppressWarnings("null") | ||
64 | public String getUsername() { | ||
65 | return getUserData().getString("username"); | ||
66 | } | ||
67 | |||
68 | @Nonnull | ||
69 | @SuppressWarnings("null") | ||
70 | private JSONObject getUserData() { | ||
71 | return this.userData.get(() -> getJson(URL_USER).getObject()); | ||
72 | } | ||
73 | |||
74 | @Nonnull | ||
75 | @SuppressWarnings("null") | ||
76 | public Set<String> getLibraryIds() { | ||
77 | return this.libraryIds | ||
78 | .get(() -> stream(getJson(URL_LIBRARY).getArray()).map(Object::toString).collect(toUnmodifiableSet())); | ||
79 | } | ||
80 | |||
81 | @Nullable | ||
82 | public Game resolveGame(@Nonnull String id) { | ||
83 | return this.games.computeIfAbsent(id, this::resolveGameDirectly); | ||
84 | } | ||
85 | |||
86 | @Nullable | ||
87 | @SuppressWarnings("null") | ||
88 | public Game resolveGameDirectly(@Nonnull String id) { | ||
89 | if (!getLibraryIds().contains(id)) { | ||
90 | warn("User @|bold %s|@ does not own game @|bold %s|@.", getUsername(), id); | ||
91 | return null; | ||
92 | } | ||
93 | |||
94 | var json = getJson(URL_GAME_DETAILS.formatted(id)); | ||
95 | if (json.isArray() && json.getArray().isEmpty()) // is a dlc | ||
96 | return null; | ||
97 | else | ||
98 | return Game.fromJson(this, json.getObject(), id); | ||
99 | } | ||
100 | |||
101 | @Nonnull | ||
102 | @SuppressWarnings("null") | ||
103 | public JsonNode getJson(@Nonnull String url) { | ||
104 | return checkResponse(url, get(url).asJson()).getBody(); | ||
105 | } | ||
106 | |||
107 | @SuppressWarnings("resource") | ||
108 | public void downloadTo(@Nonnull GameDownload download, @Nonnull Path output, | ||
109 | @Nullable ProgressBar monitor) throws IOException { | ||
110 | var parent = output.getParent(); | ||
111 | if (parent != null) | ||
112 | createDirectories(parent); | ||
113 | |||
114 | var temp = new File((parent == null ? "" : parent.toString() + separatorChar) + '.' + | ||
115 | output.getFileName().toString() + | ||
116 | ".part"); | ||
117 | temp.deleteOnExit(); // NOSONAR it's good enough | ||
118 | |||
119 | var req = get(download.url()); | ||
120 | if (monitor != null) { | ||
121 | req.downloadMonitor((_1, _2, downloaded, total) -> { | ||
122 | if (monitor.getMax() == 1) | ||
123 | monitor.maxHint(total); | ||
124 | monitor.stepTo(downloaded); | ||
125 | }); | ||
126 | } | ||
127 | |||
128 | var outputFile = checkResponse(download.originalUrl(), req.asFile(temp.getPath())).getBody(); | ||
129 | if (download.platform() == LINUX) | ||
130 | outputFile.setExecutable(true, false); // NOSONAR doesn't matter much | ||
131 | |||
132 | if (!outputFile.renameTo(output.toFile())) | ||
133 | throw new IOException("Couldn't rename the part file"); | ||
134 | } | ||
135 | |||
136 | @Nonnull | ||
137 | @SuppressWarnings("null") | ||
138 | public String resolveUrl(@Nonnull GameDownload download) { | ||
139 | var location = download.originalUrl(); | ||
140 | try (var unirest = Unirest.spawnInstance()) { | ||
141 | unirest.config().followRedirects(false); | ||
142 | |||
143 | for (int i = 0; i < 10; i++) { | ||
144 | var newLocation = UNIREST_NO_REDIRECT.get(location) | ||
145 | .cookie("gog-al", this.token) | ||
146 | .asEmpty() | ||
147 | .getHeaders() | ||
148 | .all() | ||
149 | .stream() | ||
150 | .filter(h -> h.getName().equalsIgnoreCase("location")) | ||
151 | .findFirst() | ||
152 | .map(Header::getValue); | ||
153 | |||
154 | if (newLocation.isPresent()) | ||
155 | location = newLocation.get(); | ||
156 | else | ||
157 | return location; | ||
158 | } | ||
159 | } | ||
160 | |||
161 | throw new RuntimeException("Encountered a redirect loop on " + download.originalUrl()); | ||
162 | } | ||
163 | |||
164 | public GetRequest get(@Nonnull String url) { | ||
165 | return Unirest.get(url).cookie("gog-al", this.token); | ||
166 | } | ||
167 | |||
168 | } | ||
diff --git a/src/main/java/zajc/gogarchiver/exception/NotLoggedInException.java b/src/main/java/zajc/gogarchiver/exception/NotLoggedInException.java new file mode 100644 index 0000000..15d1a86 --- /dev/null +++ b/src/main/java/zajc/gogarchiver/exception/NotLoggedInException.java | |||
@@ -0,0 +1,25 @@ | |||
1 | //SPDX-License-Identifier: GPL-3.0 | ||
2 | /* | ||
3 | * gogarchiver-ng, an archival tool for GOG.com | ||
4 | * Copyright (C) 2024 Marko Zajc | ||
5 | * | ||
6 | * This program is free software: you can redistribute it and/or modify it under the | ||
7 | * terms of the GNU General Public License as published by the Free Software | ||
8 | * Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, but WITHOUT ANY | ||
11 | * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A | ||
12 | * PARTICULAR PURPOSE. See the GNU General Public License for more details. | ||
13 | * | ||
14 | * You should have received a copy of the GNU General Public License along with this | ||
15 | * program. If not, see <https://www.gnu.org/licenses/>. | ||
16 | */ | ||
17 | package zajc.gogarchiver.exception; | ||
18 | |||
19 | public class NotLoggedInException extends Exception { | ||
20 | |||
21 | public NotLoggedInException() { | ||
22 | super(); | ||
23 | } | ||
24 | |||
25 | } | ||
diff --git a/src/main/java/zajc/gogarchiver/util/LazyValue.java b/src/main/java/zajc/gogarchiver/util/LazyValue.java new file mode 100644 index 0000000..118193f --- /dev/null +++ b/src/main/java/zajc/gogarchiver/util/LazyValue.java | |||
@@ -0,0 +1,42 @@ | |||
1 | //SPDX-License-Identifier: GPL-3.0 | ||
2 | /* | ||
3 | * gogarchiver-ng, an archival tool for GOG.com | ||
4 | * Copyright (C) 2024 Marko Zajc | ||
5 | * | ||
6 | * This program is free software: you can redistribute it and/or modify it under the | ||
7 | * terms of the GNU General Public License as published by the Free Software | ||
8 | * Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, but WITHOUT ANY | ||
11 | * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A | ||
12 | * PARTICULAR PURPOSE. See the GNU General Public License for more details. | ||
13 | * | ||
14 | * You should have received a copy of the GNU General Public License along with this | ||
15 | * program. If not, see <https://www.gnu.org/licenses/>. | ||
16 | */ | ||
17 | package zajc.gogarchiver.util; | ||
18 | |||
19 | import java.util.function.Supplier; | ||
20 | |||
21 | import javax.annotation.*; | ||
22 | |||
23 | public class LazyValue<T> { | ||
24 | |||
25 | @Nullable private volatile T value; | ||
26 | |||
27 | public T get(@Nonnull Supplier<T> generator) { | ||
28 | if (this.value != null) | ||
29 | return this.value; | ||
30 | synchronized (this) { | ||
31 | if (this.value != null) // double checked locking | ||
32 | return this.value; | ||
33 | else | ||
34 | return this.value = generator.get(); | ||
35 | } | ||
36 | } | ||
37 | |||
38 | public synchronized void unset() { | ||
39 | this.value = null; | ||
40 | } | ||
41 | |||
42 | } | ||
diff --git a/src/main/java/zajc/gogarchiver/util/Utilities.java b/src/main/java/zajc/gogarchiver/util/Utilities.java new file mode 100644 index 0000000..d039ab1 --- /dev/null +++ b/src/main/java/zajc/gogarchiver/util/Utilities.java | |||
@@ -0,0 +1,80 @@ | |||
1 | //SPDX-License-Identifier: GPL-3.0 | ||
2 | /* | ||
3 | * gogarchiver-ng, an archival tool for GOG.com | ||
4 | * Copyright (C) 2024 Marko Zajc | ||
5 | * | ||
6 | * This program is free software: you can redistribute it and/or modify it under the | ||
7 | * terms of the GNU General Public License as published by the Free Software | ||
8 | * Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, but WITHOUT ANY | ||
11 | * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A | ||
12 | * PARTICULAR PURPOSE. See the GNU General Public License for more details. | ||
13 | * | ||
14 | * You should have received a copy of the GNU General Public License along with this | ||
15 | * program. If not, see <https://www.gnu.org/licenses/>. | ||
16 | */ | ||
17 | package zajc.gogarchiver.util; | ||
18 | |||
19 | import static java.lang.System.err; | ||
20 | import static picocli.CommandLine.Help.defaultColorScheme; | ||
21 | |||
22 | import java.util.stream.*; | ||
23 | |||
24 | import javax.annotation.*; | ||
25 | |||
26 | import kong.unirest.HttpResponse; | ||
27 | import kong.unirest.json.JSONArray; | ||
28 | import picocli.CommandLine.Help.*; | ||
29 | |||
30 | public class Utilities { | ||
31 | |||
32 | private static boolean enableVerbose = false; | ||
33 | private static ColorScheme colorScheme; | ||
34 | |||
35 | public static void setVerbose(boolean verbose) { | ||
36 | enableVerbose = verbose; | ||
37 | } | ||
38 | |||
39 | public static void setColorMode(@Nonnull Ansi ansi) { | ||
40 | colorScheme = defaultColorScheme(ansi); | ||
41 | } | ||
42 | |||
43 | public static void println(@Nullable Object text) { | ||
44 | err.println(colorScheme.text(String.valueOf(text))); | ||
45 | } | ||
46 | |||
47 | public static void printf(@Nonnull String format, @Nonnull Object... args) { | ||
48 | err.print(colorScheme.text(format.formatted(args))); | ||
49 | } | ||
50 | |||
51 | public static void warn(@Nonnull String text, @Nonnull Object... args) { | ||
52 | println("@|yellow [W]|@ " + text.formatted(args)); | ||
53 | } | ||
54 | |||
55 | public static void verbose(@Nonnull String text, @Nonnull Object... args) { | ||
56 | if (enableVerbose) | ||
57 | println("@|faint [V]|@ " + text.formatted(args)); | ||
58 | } | ||
59 | |||
60 | public static <T> HttpResponse<T> checkResponse(String url, HttpResponse<T> resp) { | ||
61 | if (!resp.isSuccess()) | ||
62 | throw new RuntimeException("Got a bad HTTP response on %s: %d %s".formatted(url, resp.getStatus(), | ||
63 | resp.getStatusText())); | ||
64 | |||
65 | return resp; | ||
66 | } | ||
67 | |||
68 | @Nonnull | ||
69 | @SuppressWarnings("null") | ||
70 | public static Stream<Object> stream(JSONArray array) { | ||
71 | return StreamSupport.stream(array.spliterator(), false); | ||
72 | } | ||
73 | |||
74 | public static void cursorUp() { | ||
75 | err.print("\u001b[1A"); | ||
76 | } | ||
77 | |||
78 | private Utilities() {} | ||
79 | |||
80 | } | ||