aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/zajc/gogarchiver
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/zajc/gogarchiver')
-rw-r--r--src/main/java/zajc/gogarchiver/Arguments.java206
-rw-r--r--src/main/java/zajc/gogarchiver/Main.java246
-rw-r--r--src/main/java/zajc/gogarchiver/api/Game.java103
-rw-r--r--src/main/java/zajc/gogarchiver/api/GameDlc.java69
-rw-r--r--src/main/java/zajc/gogarchiver/api/GameDownload.java149
-rw-r--r--src/main/java/zajc/gogarchiver/api/User.java168
-rw-r--r--src/main/java/zajc/gogarchiver/exception/NotLoggedInException.java25
-rw-r--r--src/main/java/zajc/gogarchiver/util/LazyValue.java42
-rw-r--r--src/main/java/zajc/gogarchiver/util/Utilities.java80
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 */
17package zajc.gogarchiver;
18
19import static java.lang.Runtime.getRuntime;
20import static java.util.Collections.unmodifiableSet;
21import static java.util.stream.Collectors.toUnmodifiableSet;
22import static picocli.CommandLine.Help.Ansi.AUTO;
23import static picocli.CommandLine.Help.Visibility.ALWAYS;
24import static zajc.gogarchiver.api.GameDownload.Type.*;
25
26import java.io.IOException;
27import java.nio.file.*;
28import java.util.*;
29import java.util.function.Predicate;
30
31import javax.annotation.Nonnull;
32
33import org.eu.zajc.ef.supplier.except.all.AESupplier;
34
35import picocli.CommandLine.*;
36import picocli.CommandLine.Help.Ansi;
37import zajc.gogarchiver.api.*;
38import zajc.gogarchiver.api.GameDownload.Platform;
39import zajc.gogarchiver.exception.NotLoggedInException;
40import zajc.gogarchiver.util.LazyValue;
41
42public 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 */
17package zajc.gogarchiver;
18
19import static java.lang.Long.MAX_VALUE;
20import static java.lang.Math.max;
21import static java.lang.System.*;
22import static java.nio.file.Files.createDirectories;
23import static java.util.Objects.requireNonNullElse;
24import static java.util.concurrent.Executors.newFixedThreadPool;
25import static java.util.concurrent.TimeUnit.NANOSECONDS;
26import static java.util.stream.Collectors.toUnmodifiableSet;
27import static java.util.stream.Stream.concat;
28import static me.tongfei.progressbar.ProgressBarStyle.*;
29import static picocli.CommandLine.Help.Ansi.OFF;
30import static zajc.gogarchiver.util.Utilities.*;
31
32import java.io.IOException;
33import java.util.*;
34import java.util.concurrent.*;
35import java.util.stream.Stream;
36
37import javax.annotation.*;
38
39import org.eu.zajc.ef.runnable.except.all.AERunnable;
40
41import me.tongfei.progressbar.*;
42import picocli.CommandLine;
43import picocli.CommandLine.*;
44import sun.misc.Signal; // NOSONAR it's just quality of life
45import zajc.gogarchiver.api.*;
46import 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)
50public 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 */
17package zajc.gogarchiver.api;
18
19import static java.util.Objects.hash;
20import static zajc.gogarchiver.util.Utilities.stream;
21
22import java.util.*;
23
24import javax.annotation.Nonnull;
25
26import kong.unirest.json.*;
27import zajc.gogarchiver.api.GameDownload.Platform;
28
29public 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 */
17package zajc.gogarchiver.api;
18
19import java.util.Objects;
20
21import javax.annotation.Nonnull;
22
23import kong.unirest.json.*;
24
25public 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 */
17package zajc.gogarchiver.api;
18
19import static java.lang.Integer.parseInt;
20import static java.lang.String.format;
21import static java.nio.charset.StandardCharsets.UTF_8;
22import static java.util.Objects.hash;
23import static java.util.regex.Pattern.compile;
24import static zajc.gogarchiver.api.GameDownload.Type.*;
25import static zajc.gogarchiver.util.Utilities.warn;
26
27import java.io.IOException;
28import java.net.URLDecoder;
29import java.nio.file.Path;
30import java.util.Objects;
31import java.util.regex.Pattern;
32
33import javax.annotation.*;
34
35import kong.unirest.json.JSONObject;
36import me.tongfei.progressbar.ProgressBar;
37import zajc.gogarchiver.util.LazyValue;
38
39public 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 */
17package zajc.gogarchiver.api;
18
19import static java.io.File.separatorChar;
20import static java.nio.file.Files.createDirectories;
21import static java.util.stream.Collectors.toUnmodifiableSet;
22import static zajc.gogarchiver.api.GameDownload.Platform.LINUX;
23import static zajc.gogarchiver.util.Utilities.*;
24
25import java.io.*;
26import java.nio.file.Path;
27import java.util.*;
28import java.util.concurrent.ConcurrentHashMap;
29
30import javax.annotation.*;
31
32import kong.unirest.*;
33import kong.unirest.json.JSONObject;
34import me.tongfei.progressbar.ProgressBar;
35import zajc.gogarchiver.exception.NotLoggedInException;
36import zajc.gogarchiver.util.LazyValue;
37
38public 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 */
17package zajc.gogarchiver.exception;
18
19public 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 */
17package zajc.gogarchiver.util;
18
19import java.util.function.Supplier;
20
21import javax.annotation.*;
22
23public 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 */
17package zajc.gogarchiver.util;
18
19import static java.lang.System.err;
20import static picocli.CommandLine.Help.defaultColorScheme;
21
22import java.util.stream.*;
23
24import javax.annotation.*;
25
26import kong.unirest.HttpResponse;
27import kong.unirest.json.JSONArray;
28import picocli.CommandLine.Help.*;
29
30public 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}