Skip to content

Commit

Permalink
Gogg version 0.3.0
Browse files Browse the repository at this point in the history
- Username and password are no longer stored in the internal database.
- Improved the login process: Gogg tries to log in using headless mode first and then retries in windowed mode if login in headless mode fails.
- Simplified the user interface by replacing the init and auth commands with the login command.
- Added a new top-level command named file for file-related tasks, like computing hashes for downloaded files.
- Added the feature to download and store all the game files for a game in one directory (via the flatten flag).
- URL-encoded characters are now decoded during download and will correctly appear in the file names.
- Added an updated demo.
- Updated the main README file and documentation to reflect the updated user interface.
- Added docstrings to all functions.
  • Loading branch information
habedi authored Jan 20, 2025
1 parent ef4da5e commit a8ffe79
Show file tree
Hide file tree
Showing 27 changed files with 849 additions and 504 deletions.
43 changes: 22 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
</a>
<br>
<a href="https://goreportcard.com/report/github.com/habedi/gogg">
<img src="https://goreportcard.com/badge/github.com/habedi/gogg" alt="Go Report Card">
<img src="https://goreportcard.com/badge/github.com/habedi/gogg" alt="Go Report Card">
</a>
<a href="https://pkg.go.dev/github.com/habedi/gogg">
<img src="https://pkg.go.dev/badge/github.com/habedi/gogg.svg" alt="Go Reference">
Expand Down Expand Up @@ -77,49 +77,50 @@ Run `gogg -h` to see the available commands and options.

For more detailed examples, see the content of the [examples](docs/examples/) directory.

#### First Time Setup
#### Login to GOG

```bash
# Will ask for the GOG username and password
gogg init
# First-time using Gogg, you need to login to GOG to authenticate
gogg login
```

#### Authentication with GOG

```bash
# On first time try will open a browser window to authenticate with GOG,
# afterwards running `auth` will reauthenticate with GOG without opening a browser window
gogg auth
```

> You must have [Google Chrome](https://www.google.com/chrome/) or [Chromium](https://www.chromium.org/) installed
> on your machine for the first-time authentication.
> So, make sure you have one of them installed.
> [!IMPORTANT]
> You need to have [Google Chrome](https://www.google.com/chrome/) or [Chromium](https://www.chromium.org/) installed on
> your machine for the first-time authentication.
> So, make sure you have one of them installed and available in your system's PATH.
#### Syncing the Game Catalogue

```bash
# Will fetch the up-to-date information about the games you own on GOG
gogg catalogue refresh --threads=10
gogg catalogue refresh
```

#### Searching for Games

```bash
# Will search for games with the the term `Witcher` in their title
gogg catalogue search --term="Witcher"
# Will show the game ID and title of the games that contain "Witcher" in their title
gogg catalogue search "Witcher"
```

#### Downloading a Game

```bash
# Will download the game files for `The Witcher: Enhanced Edition` to `./games` directory (without extra content)
gogg download --id=1207658924 --dir=./games --platform=windows --lang=en --dlcs=true --extras=false --resume=true --threads 5
# Will download the files for `The Witcher: Enhanced Edition` to `./games` directory (without extra content)
gogg download 1207658924 ./games --platform=windows --lang=en --dlcs=true --extras=false \
--resume=true --threads 5 --flatten=true
```

#### File Hashes (For Verification)

```bash
# Will show the SHA1 hash of the downloaded files for `The Witcher: Enhanced Edition`
gogg file hash ./games/the-witcher-enhanced-edition --algo=sha1
```

## Demo

[![asciicast](https://asciinema.org/a/AVvvpkicWHINxmaX0Gsk7H8xL.svg)](https://asciinema.org/a/AVvvpkicWHINxmaX0Gsk7H8xL)
[![asciicast](https://asciinema.org/a/kXMGRUUV149R37IEmZKtTH7nI.svg)](https://asciinema.org/a/kXMGRUUV149R37IEmZKtTH7nI)

## Contributing

Expand Down
7 changes: 7 additions & 0 deletions client/data_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ import (
"testing"
)

// UnmarshalGameData unmarshals the provided JSON string into a Game object.
// It takes a testing.T object and a JSON string as parameters and returns a pointer to the Game object.
func UnmarshalGameData(t *testing.T, jsonData string) *client.Game {
var game client.Game
err := json.Unmarshal([]byte(jsonData), &game)
require.NoError(t, err)
return &game
}

// TestParsesDownloadsCorrectly tests the parsing of downloads from the JSON data.
func TestParsesDownloadsCorrectly(t *testing.T) {
jsonData := `{
"title": "Test Game",
Expand All @@ -35,6 +38,7 @@ func TestParsesDownloadsCorrectly(t *testing.T) {
assert.Equal(t, "1GB", game.Downloads[0].Platforms.Windows[0].Size)
}

// TestParsesDLCsCorrectly tests the parsing of DLCs from the JSON data.
func TestParsesDLCsCorrectly(t *testing.T) {
jsonData := `{
"title": "Test Game",
Expand All @@ -60,6 +64,7 @@ func TestParsesDLCsCorrectly(t *testing.T) {
assert.Equal(t, "500MB", game.DLCs[0].ParsedDownloads[0].Platforms.Windows[0].Size)
}

// TestIgnoresInvalidDownloads tests that invalid downloads are ignored during parsing.
func TestIgnoresInvalidDownloads(t *testing.T) {
jsonData := `{
"title": "Test Game",
Expand All @@ -79,6 +84,7 @@ func TestIgnoresInvalidDownloads(t *testing.T) {
assert.Equal(t, "1GB", game.Downloads[0].Platforms.Windows[0].Size)
}

// TestParsesExtrasCorrectly tests the parsing of extras from the JSON data.
func TestParsesExtrasCorrectly(t *testing.T) {
jsonData := `{
"title": "Test Game",
Expand All @@ -96,6 +102,7 @@ func TestParsesExtrasCorrectly(t *testing.T) {
assert.Equal(t, "http://example.com/soundtrack", game.Extras[0].ManualURL)
}

// TestHandlesEmptyDownloads tests that the Game object handles empty downloads correctly.
func TestHandlesEmptyDownloads(t *testing.T) {
jsonData := `{
"title": "Test Game",
Expand Down
60 changes: 45 additions & 15 deletions client/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import (
"github.com/schollz/progressbar/v3"
"io"
"net/http"
netURL "net/url"
"os"
"path/filepath"
"strings"
"sync"
)

// ParseGameData parses the raw game data JSON into a Game struct.
// It takes a JSON string as input and returns a Game struct and an error if the parsing fails.
func ParseGameData(data string) (Game, error) {
var rawResponse Game
if err := json.Unmarshal([]byte(data), &rawResponse); err != nil {
Expand All @@ -24,6 +26,7 @@ func ParseGameData(data string) (Game, error) {
}

// ensureDirExists checks if a directory exists and creates it if it doesn't.
// It takes a directory path as input and returns an error if the directory cannot be created.
func ensureDirExists(path string) error {
info, err := os.Stat(path)
if err == nil {
Expand All @@ -39,8 +42,9 @@ func ensureDirExists(path string) error {
return err
}

// sanitizePath sanitizes a string to be used as a file path by removing special characters, spaces, and converting to lowercase.
func sanitizePath(name string) string {
// SanitizePath sanitizes a string to be used as a file path by removing special characters, spaces, and converting to lowercase.
// It takes a string as input and returns a sanitized string.
func SanitizePath(name string) string {
replacements := []struct {
old string
new string
Expand All @@ -58,18 +62,21 @@ func sanitizePath(name string) string {
return name
}

// downloadTask represents a download task
// downloadTask represents a download task with URL, file name, sub-directory, resume, and flatten flags.
type downloadTask struct {
url string
fileName string
subDir string
resume bool
flatten bool
}

// DownloadGameFiles downloads the game files, extras, and DLCs to the specified path.
// It takes various parameters including access token, game data, download path, language, platform, flags for extras, DLCs, resume, flatten, and number of threads.
// It returns an error if the download fails.
func DownloadGameFiles(accessToken string, game Game, downloadPath string,
gameLanguage string, platformName string, extrasFlag bool, dlcFlag bool, resumeFlag bool,
numThreads int) error {
flattenFlag bool, numThreads int) error {
client := &http.Client{}
clientNoRedirect := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
Expand All @@ -78,7 +85,7 @@ func DownloadGameFiles(accessToken string, game Game, downloadPath string,
}

if err := ensureDirExists(downloadPath); err != nil {
log.Error().Err(err).Msgf("Failed to prepare output path %s", downloadPath)
log.Error().Err(err).Msgf("Failed to create download path %s", downloadPath)
return err
}

Expand Down Expand Up @@ -111,18 +118,38 @@ func DownloadGameFiles(accessToken string, game Game, downloadPath string,
}

downloadFiles := func(task downloadTask) error {

// Error variable to store the error
var err error

url := task.url
fileName := task.fileName
subDir := task.subDir
resume := task.resume
flatten := flattenFlag

if location := findFileLocation(url); location != "" {
url = location
fileName = filepath.Base(location)
}

subDir = sanitizePath(subDir)
gameTitle := sanitizePath(game.Title)
// Decode URL-encoded characters in the file name (e.g. %20 -> space)
decodedFileName, err := netURL.QueryUnescape(fileName)
if err != nil {
log.Error().Err(err).Msgf("Failed to decode file name %s", fileName)
return err
}
fileName = decodedFileName

// Don't make additional directories if flatten is enabled
if flatten {
subDir = ""
} else {
subDir = SanitizePath(subDir)
}

// Sanitize the game title to be used as base directory name
gameTitle := SanitizePath(game.Title)
filePath := filepath.Join(downloadPath, gameTitle, subDir, fileName)

if err := ensureDirExists(filepath.Dir(filePath)); err != nil {
Expand All @@ -131,7 +158,6 @@ func DownloadGameFiles(accessToken string, game Game, downloadPath string,
}

var file *os.File
var err error
var startOffset int64

// Check if resuming is enabled and the file already exists
Expand Down Expand Up @@ -278,7 +304,8 @@ func DownloadGameFiles(accessToken string, game Game, downloadPath string,

url := fmt.Sprintf("https://embed.gog.com%s", *file.ManualURL)
fileName := filepath.Base(*file.ManualURL)
taskChan <- downloadTask{url, fileName, platformFiles.subDir, resumeFlag}
taskChan <- downloadTask{url, fileName, platformFiles.subDir,
resumeFlag, flattenFlag}
}
}
}
Expand All @@ -291,8 +318,9 @@ func DownloadGameFiles(accessToken string, game Game, downloadPath string,
}

extraURL := fmt.Sprintf("https://embed.gog.com%s", extra.ManualURL)
extraFileName := sanitizePath(extra.Name)
taskChan <- downloadTask{extraURL, extraFileName, "extras", resumeFlag}
extraFileName := SanitizePath(extra.Name)
taskChan <- downloadTask{extraURL, extraFileName, "extras",
resumeFlag, flattenFlag}
}

if dlcFlag {
Expand Down Expand Up @@ -328,7 +356,8 @@ func DownloadGameFiles(accessToken string, game Game, downloadPath string,
url := fmt.Sprintf("https://embed.gog.com%s", *file.ManualURL)
fileName := filepath.Base(*file.ManualURL)
subDir := filepath.Join("dlcs", platformFiles.subDir)
taskChan <- downloadTask{url, fileName, subDir, resumeFlag}
taskChan <- downloadTask{url, fileName, subDir,
resumeFlag, flattenFlag}
}
}
}
Expand All @@ -341,9 +370,10 @@ func DownloadGameFiles(accessToken string, game Game, downloadPath string,
}

extraURL := fmt.Sprintf("https://embed.gog.com%s", extra.ManualURL)
extraFileName := sanitizePath(extra.Name)
extraFileName := SanitizePath(extra.Name)
subDir := filepath.Join("dlcs", "extras")
taskChan <- downloadTask{extraURL, extraFileName, subDir, resumeFlag}
taskChan <- downloadTask{extraURL, extraFileName,
subDir, resumeFlag, flattenFlag}
}
}
}
Expand All @@ -356,7 +386,7 @@ func DownloadGameFiles(accessToken string, game Game, downloadPath string,
log.Error().Err(err).Msg("Failed to encode game metadata")
return err
}
metadataPath := filepath.Join(downloadPath, sanitizePath(game.Title), "metadata.json")
metadataPath := filepath.Join(downloadPath, SanitizePath(game.Title), "metadata.json")
if err := os.WriteFile(metadataPath, metadata, 0644); err != nil {
log.Error().Err(err).Msgf("Failed to save game metadata to %s", metadataPath)
return err
Expand Down
7 changes: 7 additions & 0 deletions client/games.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
)

// FetchGameData retrieves the game data for the specified game from GOG.
// It takes an access token and a URL as parameters and returns a Game struct, the raw response body as a string, and an error if the operation fails.
func FetchGameData(accessToken string, url string) (Game, string, error) {
//url := fmt.Sprintf("https://embed.gog.com/account/gameDetails/%d.json", gameID)
req, err := createRequest("GET", url, accessToken)
Expand Down Expand Up @@ -37,6 +38,7 @@ func FetchGameData(accessToken string, url string) (Game, string, error) {
}

// FetchIdOfOwnedGames retrieves the list of game IDs that the user owns from GOG.
// It takes an access token and an API URL as parameters and returns a slice of integers representing the game IDs and an error if the operation fails.
func FetchIdOfOwnedGames(accessToken string, apiURL string) ([]int, error) {
//apiURL := "https://embed.gog.com/user/data/games"
req, err := createRequest("GET", apiURL, accessToken)
Expand All @@ -59,6 +61,7 @@ func FetchIdOfOwnedGames(accessToken string, apiURL string) ([]int, error) {
}

// createRequest creates an HTTP request with the specified method, URL, and access token from GOG.
// It returns a pointer to the http.Request object and an error if the request creation fails.
func createRequest(method, url, accessToken string) (*http.Request, error) {
req, err := http.NewRequest(method, url, nil)
if err != nil {
Expand All @@ -70,6 +73,7 @@ func createRequest(method, url, accessToken string) (*http.Request, error) {
}

// sendRequest sends an HTTP request and returns the response.
// It takes a pointer to the http.Request object as a parameter and returns a pointer to the http.Response object and an error if the request fails.
func sendRequest(req *http.Request) (*http.Response, error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
Expand All @@ -85,6 +89,7 @@ func sendRequest(req *http.Request) (*http.Response, error) {
}

// readResponseBody reads the response body and returns it as a byte slice.
// It takes a pointer to the http.Response object as a parameter and returns the response body as a byte slice and an error if the reading fails.
func readResponseBody(resp *http.Response) ([]byte, error) {
body, err := io.ReadAll(resp.Body)
if err != nil {
Expand All @@ -95,6 +100,7 @@ func readResponseBody(resp *http.Response) ([]byte, error) {
}

// parseGameData parses the game data from the response body.
// It takes the response body as a byte slice and a pointer to the Game struct as parameters and returns an error if the parsing fails.
func parseGameData(body []byte, game *Game) error {
if err := json.Unmarshal(body, game); err != nil {
log.Error().Err(err).Msg("Failed to parse game data")
Expand All @@ -104,6 +110,7 @@ func parseGameData(body []byte, game *Game) error {
}

// parseOwnedGames parses the list of owned game IDs from the response body.
// It takes the response body as a byte slice and returns a slice of integers representing the game IDs and an error if the parsing fails.
func parseOwnedGames(body []byte) ([]int, error) {
var response struct {
Owned []int `json:"owned"`
Expand Down
Loading

0 comments on commit a8ffe79

Please sign in to comment.