Skip to content

Commit 35d636d

Browse files
authored
Feat: Download extras (#66)
1 parent 0d407d4 commit 35d636d

File tree

11 files changed

+193
-28
lines changed

11 files changed

+193
-28
lines changed

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,30 @@ So to download only games that support Czech either in-game or as a separate dow
166166
> Tip: If you want to see how much space you need for your games, replace the `download` command with `total-size`,
167167
> it includes most of the same parameters.
168168
169+
### Downloading extras
170+
171+
Extras can be downloaded simply by adding `--extras` flag to the above command:
172+
173+
- `gog-downloader download --extras`
174+
175+
This will download both games and extras. If you wish to download only extras, you can add the `--no-games` flag:
176+
177+
- `gog-downloader download --extras --no-games`
178+
179+
Because extras generally don't contain a hash that can be used to check the validity of the downloaded content,
180+
the extras will be downloaded every time. You can use the already mentioned `--no-verify` flag, but that will also
181+
skip verifying games. If you want to only skip downloading existing extras, you can use the `--skip-existing-extras`
182+
flag.
183+
184+
If, for some reason, you want to skip any download, you can exclude it by name using `--skip-download`:
185+
186+
- `gog-downloader download --extras --no-games --skip-existing-extras --skip-download "Cyberpunk 2077 Legacy 1.63"`
187+
188+
This will skip the extra called *Cyberpunk 2077 Legacy 1.63*.
189+
190+
> Tip: Use the `-v` flag to print information about skipped games, by default they're skipped silently to not clutter
191+
> the output.
192+
169193
### Downloading saves
170194

171195
To download cloud saves you can use the `download-saves` command: `gog-downloader download-saves` (or the shorthand

src/Command/DownloadCommand.php

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
namespace App\Command;
44

5+
use App\DTO\DownloadDescription;
6+
use App\DTO\GameExtra;
57
use App\Enum\Language;
68
use App\Enum\NamingConvention;
79
use App\Enum\Setting;
810
use App\Exception\ExitException;
911
use App\Exception\InvalidValueException;
1012
use App\Exception\TooManyRetriesException;
1113
use App\Exception\UnreadableFileException;
14+
use App\Helper\LatelyBoundStringValue;
1215
use App\Service\DownloadManager;
1316
use App\Service\FileWriter\FileWriterLocator;
1417
use App\Service\Iterables;
@@ -93,6 +96,27 @@ protected function configure()
9396
mode: InputOption::VALUE_REQUIRED,
9497
description: 'Specify the maximum download speed in bytes. You can use the k postfix for kilobytes or m postfix for megabytes (for example 200k or 4m to mean 200 kilobytes and 4 megabytes respectively)',
9598
)
99+
->addOption(
100+
name: 'extras',
101+
shortcut: 'e',
102+
mode: InputOption::VALUE_NONE,
103+
description: 'Whether to include extras or not.',
104+
)
105+
->addOption(
106+
name: 'skip-existing-extras',
107+
mode: InputOption::VALUE_NONE,
108+
description: "Unlike games, extras generally don't have a hash that can be used to check whether the downloaded content is the same as the remote one, meaning by default extras will be downloaded every time, even if they exist. By providing this flag, you will skip existing extras."
109+
)
110+
->addOption(
111+
name: 'no-games',
112+
mode: InputOption::VALUE_NONE,
113+
description: 'Skip downloading games. Should be used with other options like --extras if you want to only download those.'
114+
)
115+
->addOption(
116+
name: 'skip-download',
117+
mode: InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
118+
description: 'Skip a download by its name, can be specified multiple times.'
119+
)
96120
;
97121
}
98122

@@ -128,15 +152,25 @@ protected function execute(InputInterface $input, OutputInterface $output): int
128152

129153
$timeout = $input->getOption('idle-timeout');
130154
$noVerify = $input->getOption('no-verify');
155+
$skipExistingExtras = $input->getOption('skip-existing-extras');
131156
$iterable = $this->getGames($input, $output, $this->ownedItemsManager);
157+
$downloadsToSkip = $input->getOption('skip-download');
132158

133159
$this->dispatchSignals();
134160
foreach ($iterable as $game) {
135-
$downloads = $game->downloads;
161+
$downloads = [];
162+
if (!$input->getOption('no-games')) {
163+
$downloads = [...$downloads, ...$game->downloads];
164+
}
165+
if ($input->getOption('extras')) {
166+
$downloads = [...$downloads, ...$game->extras];
167+
}
136168

137169
foreach ($downloads as $download) {
138170
try {
139171
$this->retryService->retry(function () use (
172+
$downloadsToSkip,
173+
$skipExistingExtras,
140174
$chunkSize,
141175
$timeout,
142176
$noVerify,
@@ -147,6 +181,26 @@ protected function execute(InputInterface $input, OutputInterface $output): int
147181
$download,
148182
$io,
149183
) {
184+
assert($download instanceof DownloadDescription || $download instanceof GameExtra);
185+
186+
$downloadTag = new LatelyBoundStringValue(function () use ($download, $game) {
187+
if ($download instanceof DownloadDescription) {
188+
return "[{$game->title}] {$download->name} ({$download->platform}, {$download->language})";
189+
} else if ($download instanceof GameExtra) {
190+
return "[{$game->title}] {$download->name} (extra)";
191+
}
192+
193+
throw new RuntimeException('Uncovered download type');
194+
});
195+
196+
if (in_array($download->name, $downloadsToSkip, true)) {
197+
if ($output->isVerbose()) {
198+
$io->writeln("{$downloadTag}: Skipping because it's specified using the --skip-download flag");
199+
}
200+
201+
return;
202+
}
203+
150204
$this->canKillSafely = false;
151205
$this->dispatchSignals();
152206
$progress = $io->createProgressBar();
@@ -170,29 +224,47 @@ protected function execute(InputInterface $input, OutputInterface $output): int
170224
}
171225
$filename = $this->downloadManager->getFilename($download, $timeout);
172226
if (!$filename) {
173-
throw new RuntimeException("{$download->name} ({$download->platform}, {$download->language}): Failed getting the filename for {$download->name}");
227+
throw new RuntimeException("{$downloadTag}: Failed getting the filename for {$download->name}");
228+
}
229+
230+
if ($download instanceof GameExtra) {
231+
$filename = "extras/{$filename}";
174232
}
233+
175234
$targetFile = $writer->getFileReference("{$targetDir}/{$filename}");
176235

177236
$startAt = null;
178-
if (($download->md5 || $noVerify) && $writer->exists($targetFile)) {
237+
if (
238+
(
239+
$download->md5
240+
|| $noVerify
241+
|| ($download instanceof GameExtra && $skipExistingExtras)
242+
)
243+
&& $writer->exists($targetFile)
244+
) {
179245
try {
180246
$md5 = $noVerify ? '' : $writer->getMd5Hash($targetFile);
181247
} catch (UnreadableFileException) {
182-
$io->warning("{$download->name} ({$download->platform}, {$download->language}): Tried to get existing hash of {$download->name}, but the file is not readable. It will be downloaded again");
248+
$io->warning("{$downloadTag}: Tried to get existing hash of {$download->name}, but the file is not readable. It will be downloaded again");
183249
$md5 = '';
184250
}
185251
if (!$noVerify && $download->md5 === $md5) {
186252
if ($output->isVerbose()) {
187253
$io->writeln(
188-
"{$download->name} ({$download->platform}, {$download->language}): Skipping because it exists and is valid",
254+
"{$downloadTag}: Skipping because it exists and is valid",
189255
);
190256
}
191257

258+
return;
259+
} elseif ($download instanceof GameExtra && $skipExistingExtras) {
260+
if ($output->isVerbose()) {
261+
$io->writeln("{$downloadTag}: Skipping because it exists (--skip-existing-extras specified, not checking content)");
262+
}
263+
192264
return;
193265
} elseif ($noVerify) {
194266
if ($output->isVerbose()) {
195-
$io->writeln("{$download->name} ({$download->platform}, {$download->language}): Skipping because it exists (--no-verify specified, not checking content)");
267+
$io->writeln("{$downloadTag}: Skipping because it exists (--no-verify specified, not checking content)");
196268
}
197269

198270
return;
@@ -202,7 +274,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
202274

203275
$progress->setMaxSteps(0);
204276
$progress->setProgress(0);
205-
$progress->setMessage("{$download->name} ({$download->platform}, {$download->language})");
277+
$progress->setMessage($downloadTag);
206278

207279
$curlOptions = [];
208280
if ($input->getOption('bandwidth') && defined('CURLOPT_MAX_RECV_SPEED_LARGE')) {
@@ -240,7 +312,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
240312
$writer->finalizeWriting($targetFile, $hash);
241313

242314
if (!$noVerify && $download->md5 && $download->md5 !== $hash) {
243-
$io->warning("{$download->name} ({$download->platform}, {$download->language}) failed hash check");
315+
$io->warning("{$downloadTag} failed hash check");
244316
}
245317

246318
$progress->finish();

src/DTO/FileWriter/StreamWrapperFileReference.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ public function open()
3333
{
3434
if ($this->fileHandle === null) {
3535
$mode = file_exists($this->path) ? 'a+' : 'w+';
36+
if (!is_dir(dirname($this->path))) {
37+
mkdir(dirname($this->path), 0777, true);
38+
}
3639
$this->fileHandle = fopen($this->path, $mode);
3740
}
3841

src/DTO/GameExtra.php

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
namespace App\DTO;
44

5-
final readonly class GameExtra
5+
final class GameExtra
66
{
77
public function __construct(
8-
public int $id,
9-
public string $name,
10-
public int $size,
11-
public string $url,
12-
public int $gogGameId,
8+
public readonly int $id,
9+
public readonly string $name,
10+
public readonly int $size,
11+
public readonly string $url,
12+
public readonly int $gogGameId,
13+
public private(set) ?string $md5,
1314
) {
1415
}
1516
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace App\Helper;
4+
5+
use Closure;
6+
use Stringable;
7+
8+
final class LatelyBoundStringValue implements Stringable
9+
{
10+
private ?string $value = null;
11+
12+
/**
13+
* @var Closure(): string
14+
*/
15+
private readonly Closure $callback;
16+
17+
/**
18+
* @param callable(): string $callback
19+
*/
20+
public function __construct(
21+
callable $callback,
22+
) {
23+
$this->callback = $callback(...);
24+
}
25+
26+
public function __toString()
27+
{
28+
$this->value ??= ($this->callback)();
29+
30+
return $this->value;
31+
}
32+
}

src/Migration/Migration7.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
namespace App\Migration;
4+
5+
use PDO;
6+
7+
final readonly class Migration7 implements Migration
8+
{
9+
public function migrate(PDO $pdo): void
10+
{
11+
$pdo->exec("alter table game_extras add md5 text");
12+
}
13+
14+
public function getVersion(): int
15+
{
16+
return 7;
17+
}
18+
}

src/Service/DownloadManager.php

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace App\Service;
44

55
use App\DTO\DownloadDescription;
6+
use App\DTO\GameExtra;
67
use Symfony\Component\HttpClient\Exception\ClientException;
78
use Symfony\Component\HttpFoundation\Request;
89
use Symfony\Component\HttpFoundation\Response;
@@ -19,16 +20,19 @@ public function __construct(
1920
) {
2021
}
2122

22-
public function getDownloadUrl(DownloadDescription $download): string
23-
{
23+
public function getDownloadUrl(
24+
DownloadDescription|GameExtra $download
25+
): string {
2426
if (str_starts_with($download->url, '/')) {
2527
return self::BASE_URL . $download->url;
2628
}
2729
return $download->url;
2830
}
2931

30-
public function getFilename(DownloadDescription $download, int $httpTimeout = 3): ?string
31-
{
32+
public function getFilename(
33+
DownloadDescription|GameExtra $download,
34+
int $httpTimeout = 3
35+
): ?string {
3236
$url = $this->getRealDownloadUrl($download, $httpTimeout);
3337
if (!$url) {
3438
return null;
@@ -38,7 +42,7 @@ public function getFilename(DownloadDescription $download, int $httpTimeout = 3)
3842
}
3943

4044
public function download(
41-
DownloadDescription $download,
45+
DownloadDescription|GameExtra $download,
4246
callable $callback,
4347
?int $startAt = null,
4448
int $httpTimeout = 3,
@@ -67,8 +71,10 @@ public function download(
6771
return $this->httpClient->stream($response);
6872
}
6973

70-
private function getRealDownloadUrl(DownloadDescription $download, int $httpTimeout = 3): ?string
71-
{
74+
private function getRealDownloadUrl(
75+
DownloadDescription|GameExtra $download,
76+
int $httpTimeout = 3
77+
): ?string {
7278
if (str_starts_with($download->url, '/')) {
7379
$response = $this->httpClient->request(
7480
Request::METHOD_HEAD,

src/Service/OwnedItemsManager.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use App\DTO\DownloadDescription;
66
use App\DTO\GameDetail;
7+
use App\DTO\GameExtra;
78
use App\DTO\GameInfo;
89
use App\DTO\MovieInfo;
910
use App\DTO\OwnedItemInfo;
@@ -178,7 +179,7 @@ public function getLocalGameData(): array
178179
/**
179180
* @todo move this to deserialization (duplicate requests)
180181
*/
181-
public function getChecksum(DownloadDescription $download, GameDetail $game, int $httpTimeout = 3): ?string
182+
public function getChecksum(DownloadDescription|GameExtra $download, GameDetail $game, int $httpTimeout = 3): ?string
182183
{
183184
$parts = explode('/', $download->url);
184185
$id = $parts[array_key_last($parts)];
@@ -309,6 +310,9 @@ private function getGameDetail(OwnedItemInfo $item, int $httpTimeout): ?GameDeta
309310
foreach ($detail->downloads as $download) {
310311
$this->setMd5($download, $detail, $httpTimeout);
311312
}
313+
foreach ($detail->extras as $extra) {
314+
$this->setMd5($extra, $detail, $httpTimeout);
315+
}
312316
assert($detail instanceof GameDetail);
313317

314318
return $detail;
@@ -336,7 +340,7 @@ private function getMovieDetail(OwnedItemInfo $item, int $httpTimeout): GameDeta
336340
return $detail;
337341
}
338342

339-
private function setMd5(DownloadDescription $download, GameDetail $game, int $httpTimeout)
343+
private function setMd5(DownloadDescription|GameExtra $download, GameDetail $game, int $httpTimeout)
340344
{
341345
$md5 = $this->getChecksum($download, $game, $httpTimeout);
342346
if ($md5 === null) {

0 commit comments

Comments
 (0)