diff --git a/README.md b/README.md
index 6502b90..9f63806 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@
-
+
@@ -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
diff --git a/client/data_test.go b/client/data_test.go
index 22fff49..1a85fb0 100644
--- a/client/data_test.go
+++ b/client/data_test.go
@@ -8,6 +8,8 @@ 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)
@@ -15,6 +17,7 @@ func UnmarshalGameData(t *testing.T, jsonData string) *client.Game {
return &game
}
+// TestParsesDownloadsCorrectly tests the parsing of downloads from the JSON data.
func TestParsesDownloadsCorrectly(t *testing.T) {
jsonData := `{
"title": "Test Game",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
diff --git a/client/download.go b/client/download.go
index 6595a9b..6b75dd1 100644
--- a/client/download.go
+++ b/client/download.go
@@ -7,6 +7,7 @@ import (
"github.com/schollz/progressbar/v3"
"io"
"net/http"
+ netURL "net/url"
"os"
"path/filepath"
"strings"
@@ -14,6 +15,7 @@ import (
)
// 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 {
@@ -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 {
@@ -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
@@ -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 {
@@ -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
}
@@ -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 {
@@ -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
@@ -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}
}
}
}
@@ -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 {
@@ -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}
}
}
}
@@ -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}
}
}
}
@@ -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
diff --git a/client/games.go b/client/games.go
index 0aa0c27..ccdeb75 100644
--- a/client/games.go
+++ b/client/games.go
@@ -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)
@@ -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)
@@ -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 {
@@ -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)
@@ -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 {
@@ -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")
@@ -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"`
diff --git a/client/games_test.go b/client/games_test.go
index 504e34c..3446deb 100644
--- a/client/games_test.go
+++ b/client/games_test.go
@@ -10,6 +10,8 @@ import (
"github.com/stretchr/testify/require"
)
+// TestFetchGameData_ReturnsGameData tests that FetchGameData returns the correct game data
+// when provided with a valid token and URL.
func TestFetchGameData_ReturnsGameData(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -23,6 +25,8 @@ func TestFetchGameData_ReturnsGameData(t *testing.T) {
assert.Equal(t, `{"title": "Test Game"}`, body)
}
+// TestFetchGameData_ReturnsErrorOnInvalidToken tests that FetchGameData returns an error
+// when provided with an invalid token.
func TestFetchGameData_ReturnsErrorOnInvalidToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
@@ -33,6 +37,8 @@ func TestFetchGameData_ReturnsErrorOnInvalidToken(t *testing.T) {
assert.Error(t, err)
}
+// TestFetchIdOfOwnedGames_ReturnsOwnedGames tests that FetchIdOfOwnedGames returns the correct
+// list of owned game IDs when provided with a valid token and URL.
func TestFetchIdOfOwnedGames_ReturnsOwnedGames(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -45,6 +51,8 @@ func TestFetchIdOfOwnedGames_ReturnsOwnedGames(t *testing.T) {
assert.Equal(t, []int{1, 2, 3}, ids)
}
+// TestFetchIdOfOwnedGames_ReturnsErrorOnInvalidToken tests that FetchIdOfOwnedGames returns an error
+// when provided with an invalid token.
func TestFetchIdOfOwnedGames_ReturnsErrorOnInvalidToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
@@ -55,6 +63,8 @@ func TestFetchIdOfOwnedGames_ReturnsErrorOnInvalidToken(t *testing.T) {
assert.Error(t, err)
}
+// TestFetchIdOfOwnedGames_ReturnsErrorOnInvalidResponse tests that FetchIdOfOwnedGames returns an error
+// when the response from the server is invalid.
func TestFetchIdOfOwnedGames_ReturnsErrorOnInvalidResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized) // Should raise error
diff --git a/client/auth.go b/client/login.go
similarity index 53%
rename from client/auth.go
rename to client/login.go
index ca32814..f387546 100644
--- a/client/auth.go
+++ b/client/login.go
@@ -11,72 +11,99 @@ import (
"io"
"net/http"
"net/url"
+ "os"
"os/exec"
"strings"
"time"
)
-// AuthGOG logs in to GOG.com and retrieves an access token for the user.
-func AuthGOG(authURL string, user *db.User, headless bool) error {
-
- if user == nil {
- return fmt.Errorf("user data is empy. Please run 'gogg init' to initialize the database")
- }
+var (
+ // GOGLoginURL is the URL to login to GOG.com.
+ GOGLoginURL = "https://auth.gog.com/auth?client_id=46899977096215655" +
+ "&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient" +
+ "&response_type=code&layout=client2"
+)
- if isTokenValid(user) {
- log.Info().Msg("Access token is still valid")
- return nil
- }
+// Login logs in to GOG.com and retrieves an access token for the user.
+// It takes the login URL, username, password, and a boolean indicating whether to use headless mode.
+// It returns an error if the login process fails.
+func Login(loginURL string, username string, password string, headless bool) error {
- if user.RefreshToken != "" {
- log.Info().Msg("Refreshing access token")
- return refreshToken(user)
+ if username == "" || password == "" {
+ return fmt.Errorf("username and password cannot be empty")
}
+ // Try headless login first
ctx, cancel := createChromeContext(headless)
defer cancel()
- finalURL, err := performLogin(ctx, authURL, user)
+ // Perform the login
+ finalURL, err := performLogin(ctx, loginURL, username, password)
if err != nil {
- return fmt.Errorf("failed during automated login: %w", err)
+ if headless {
+ log.Warn().Err(err).Msg("Headless login failed, retrying with window mode")
+ // Retry with window mode if headless login fails
+ ctx, cancel = createChromeContext(false)
+ defer cancel()
+
+ finalURL, err = performLogin(ctx, loginURL, username, password)
+ if err != nil {
+ return fmt.Errorf("failed to login: %w", err)
+ }
+ } else {
+ return fmt.Errorf("failed to login: %w", err)
+ }
}
+ // Extract the authorization code from the final URL after successful login
code, err := extractAuthCode(finalURL)
if err != nil {
return err
}
+ // Exchange the authorization code for an access token and a refresh token
token, refreshToken, expiresAt, err := exchangeCodeForToken(code)
if err != nil {
return fmt.Errorf("failed to exchange authorization code for token: %w", err)
}
- user.AccessToken = token
- user.RefreshToken = refreshToken
- user.ExpiresAt = expiresAt
- return db.UpsertUserData(user)
+ // Print the access token, refresh token, and expiration if debug mode is enabled
+ if os.Getenv("DEBUG_GOGG") != "" {
+ log.Info().Msgf("Access token: %s", token[:10])
+ log.Info().Msgf("Refresh token: %s", refreshToken[:10])
+ log.Info().Msgf("Expires at: %s", expiresAt)
+ }
+
+ // Save the token record in the database
+ return db.UpsertTokenRecord(&db.Token{AccessToken: token, RefreshToken: refreshToken, ExpiresAt: expiresAt})
}
-// SaveUserCredentials saves the user's credentials in the database.
-func SaveUserCredentials(username, password string) error {
- user := &db.User{
- Username: username,
- Password: password,
- AccessToken: "",
- RefreshToken: "",
- ExpiresAt: "",
+// RefreshToken refreshes the access token if it is expired and returns the updated token record.
+// It returns a pointer to the updated token record and an error if the refresh process fails.
+func RefreshToken() (*db.Token, error) {
+ token, err := db.GetTokenRecord()
+ if err != nil {
+ return nil, fmt.Errorf("failed to retrieve token record: %w", err)
}
- return db.UpsertUserData(user)
+
+ if !isTokenValid(token) {
+ if err := refreshAccessToken(token); err != nil {
+ return nil, fmt.Errorf("failed to refresh token: %w", err)
+ }
+ }
+
+ return token, nil
}
-// refreshToken refreshes the access token using the refresh token.
-func refreshToken(user *db.User) error {
+// refreshAccessToken refreshes the access token using the refresh token.
+// It takes a pointer to the token record and returns an error if the refresh process fails.
+func refreshAccessToken(token *db.Token) error {
tokenURL := "https://auth.gog.com/token"
query := url.Values{
"client_id": {"46899977096215655"},
"client_secret": {"9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"},
"grant_type": {"refresh_token"},
- "refresh_token": {user.RefreshToken},
+ "refresh_token": {token.RefreshToken},
}
resp, err := http.PostForm(tokenURL, query)
@@ -100,24 +127,25 @@ func refreshToken(user *db.User) error {
return fmt.Errorf("failed to parse token response: %w", err)
}
- user.AccessToken = result.AccessToken
- user.RefreshToken = result.RefreshToken
- user.ExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second).Format(time.RFC3339)
- return db.UpsertUserData(user)
+ token.AccessToken = result.AccessToken
+ token.RefreshToken = result.RefreshToken
+ token.ExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second).Format(time.RFC3339)
+ return db.UpsertTokenRecord(token)
}
// isTokenValid checks if the access token (stored in the database) is still valid.
-func isTokenValid(user *db.User) bool {
+// It takes a pointer to the token record and returns a boolean indicating whether the token is valid.
+func isTokenValid(token *db.Token) bool {
- if user == nil {
+ if token == nil {
return false
}
- if user.AccessToken == "" || user.ExpiresAt == "" {
+ if token.AccessToken == "" || token.ExpiresAt == "" {
return false
}
- expiresAt, err := time.Parse(time.RFC3339, user.ExpiresAt)
+ expiresAt, err := time.Parse(time.RFC3339, token.ExpiresAt)
if err != nil {
log.Error().Err(err).Msg("Invalid expiration time format")
return false
@@ -126,7 +154,8 @@ func isTokenValid(user *db.User) bool {
return time.Now().Before(expiresAt)
}
-// createChromeContext creates a new ChromeDP context.
+// createChromeContext creates a new ChromeDP context with the specified option to run Chrome in headless mode or not.
+// It returns the ChromeDP context and a cancel function to release resources.
func createChromeContext(headless bool) (context.Context, context.CancelFunc) {
// Check if Google Chrome or Chromium is available in the path
var execPath string
@@ -158,17 +187,18 @@ func createChromeContext(headless bool) (context.Context, context.CancelFunc) {
}
}
-// performLogin performs the login process using ChromeDP.
-func performLogin(ctx context.Context, authURL string, user *db.User) (string, error) {
+// performLogin performs the login process using the provided username and password and returns the final URL after successful login.
+// It takes the ChromeDP context, login URL, username, and password as parameters and returns the final URL and an error if the login process fails.
+func performLogin(ctx context.Context, loginURL string, username string, password string) (string, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
defer cancel()
var finalURL string
err := chromedp.Run(timeoutCtx,
- chromedp.Navigate(authURL),
+ chromedp.Navigate(loginURL),
chromedp.WaitVisible(`#login_username`, chromedp.ByID),
- chromedp.SendKeys(`#login_username`, user.Username, chromedp.ByID),
- chromedp.SendKeys(`#login_password`, user.Password, chromedp.ByID),
+ chromedp.SendKeys(`#login_username`, username, chromedp.ByID),
+ chromedp.SendKeys(`#login_password`, password, chromedp.ByID),
chromedp.Click(`#login_login`, chromedp.ByID),
chromedp.ActionFunc(func(ctx context.Context) error {
for {
@@ -187,7 +217,8 @@ func performLogin(ctx context.Context, authURL string, user *db.User) (string, e
return finalURL, err
}
-// extractAuthCode extracts the authorization code from the URL.
+// extractAuthCode extracts the authorization code from the URL after successful login.
+// It takes the authorization URL as a parameter and returns the authorization code and an error if the extraction fails.
func extractAuthCode(authURL string) (string, error) {
parsedURL, err := url.Parse(authURL)
if err != nil {
@@ -196,13 +227,14 @@ func extractAuthCode(authURL string) (string, error) {
code := parsedURL.Query().Get("code")
if code == "" {
- return "", errors.New("authorization code not found in URL")
+ return "", errors.New("authorization code not found in the URL")
}
return code, nil
}
// exchangeCodeForToken exchanges the authorization code for an access token and a refresh token.
+// It takes the authorization code as a parameter and returns the access token, refresh token, expiration time, and an error if the exchange process fails.
func exchangeCodeForToken(code string) (string, string, string, error) {
tokenURL := "https://auth.gog.com/token"
query := url.Values{
diff --git a/cmd/auth.go b/cmd/auth.go
deleted file mode 100644
index fae6f12..0000000
--- a/cmd/auth.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package cmd
-
-import (
- "github.com/rs/zerolog/log"
- "github.com/spf13/cobra"
-)
-
-// See https://gogapidocs.readthedocs.io/en/latest/auth.html for more information on GOG's authentication API
-
-var (
- // authURL is the URL to authenticate with GOG.com
- authURL = "https://auth.gog.com/auth?client_id=46899977096215655" +
- "&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient" +
- "&response_type=code&layout=client2"
-)
-
-// authCmd authenticates the user with GOG.com and retrieves an access token or renew the token
-func authCmd() *cobra.Command {
- var showWindow bool
-
- cmd := &cobra.Command{
- Use: "auth",
- Short: "Authenticate user account using the GOG API",
- Run: func(cmd *cobra.Command, args []string) {
- log.Info().Msg("Trying to authenticate with GOG.com")
-
- _, err := authenticateUser(showWindow)
- if err != nil {
- cmd.PrintErrln("Error: Failed to authenticate. Please make sure you have run 'gogg init', check your credentials, and try again.")
- return
- }
-
- cmd.Println("Authentication was successful.")
- },
- }
-
- cmd.Flags().BoolVarP(&showWindow, "show", "s", true, "Show the login window in the browser [true, false]")
-
- return cmd
-}
diff --git a/cmd/catalogue.go b/cmd/catalogue.go
index 35e446c..0e6862c 100644
--- a/cmd/catalogue.go
+++ b/cmd/catalogue.go
@@ -11,16 +11,19 @@ import (
"github.com/spf13/cobra"
"os"
"path/filepath"
+ "strconv"
"strings"
"sync"
"time"
)
// catalogueCmd represents the base command when called without any subcommands
+// It returns a pointer to the created cobra.Command.
func catalogueCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "catalogue",
Short: "Manage the game catalogue",
+ Long: "Manage the game catalogue by listing and searching for games, etc.",
}
// Add subcommands to the catalogue command
@@ -36,14 +39,18 @@ func catalogueCmd() *cobra.Command {
}
// listCmd shows the list of games in the catalogue
+// It returns a pointer to the created cobra.Command.
func listCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
- Short: "Show the list all games in the catalogue",
+ Short: "Show the list of games in the catalogue",
+ Long: "Show the list of all games in the catalogue in a tabular format",
Run: listGames,
}
}
+// listGames fetches and displays the list of games in the catalogue
+// It takes a cobra.Command and a slice of strings as arguments.
func listGames(cmd *cobra.Command, args []string) {
log.Info().Msg("Listing all games in the catalogue...")
@@ -90,27 +97,27 @@ func listGames(cmd *cobra.Command, args []string) {
}
// infoCmd shows detailed information about a specific game, given its ID or title
+// It returns a pointer to the created cobra.Command.
func infoCmd() *cobra.Command {
- var gameID int
cmd := &cobra.Command{
- Use: "info",
- Short: "Show information about a specific game",
+ Use: "info [gameID]",
+ Short: "Show the information about a game in the catalogue",
+ Long: "Given a game ID, show detailed information about the game with the specified ID in JSON format",
+ Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
+ gameID, err := strconv.Atoi(args[0])
+ if err != nil {
+ cmd.PrintErrln("Error: Invalid game ID. It must be a number.")
+ return
+ }
showGameInfo(cmd, gameID)
},
}
-
- // Define the flag for the command
- cmd.Flags().IntVarP(&gameID, "id", "i", 0, "ID of the game to show its information")
-
- // Mark the flag as required and handle any errors
- if err := cmd.MarkFlagRequired("id"); err != nil {
- log.Error().Err(err).Msg("Failed to mark 'id' flag as required")
- }
-
return cmd
}
+// showGameInfo fetches and displays detailed information about a game with the specified ID
+// It takes a cobra.Command and an integer representing the game ID as arguments.
func showGameInfo(cmd *cobra.Command, gameID int) {
if gameID == 0 {
cmd.PrintErrln("Error: ID of the game is required to fetch information.")
@@ -134,14 +141,28 @@ func showGameInfo(cmd *cobra.Command, gameID int) {
return
}
- // Display the game information
- cmd.Println("Game Information:")
- cmd.Printf("ID: %d\n", game.ID)
- cmd.Printf("Title: %s\n", game.Title)
- cmd.Printf("Data: %s\n", game.Data)
+ // Unmarshal the nested JSON data
+ var nestedData map[string]interface{}
+ if err := json.Unmarshal([]byte(game.Data), &nestedData); err != nil {
+ log.Error().Err(err).Msg("Failed to unmarshal nested game data")
+ cmd.PrintErrln("Error: Failed to parse nested game data.")
+ return
+ }
+
+ // Pretty print the nested JSON data
+ nestedDataPretty, err := json.MarshalIndent(nestedData, "", " ")
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to marshal nested game data")
+ cmd.PrintErrln("Error: Failed to format nested game data.")
+ return
+ }
+
+ // Print the nested JSON data
+ cmd.Println(string(nestedDataPretty))
}
// refreshCmd refreshes the game catalogue with the latest data from the user's account
+// It returns a pointer to the created cobra.Command.
func refreshCmd() *cobra.Command {
// Define the number of threads to use for fetching game data
@@ -149,17 +170,21 @@ func refreshCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "refresh",
- Short: "Update the catalogue with the latest data from the GOG account",
+ Short: "Update the catalogue with the latest data from GOG",
+ Long: "Update the game catalogue with the latest data for the games owned by the user on GOG",
Run: func(cmd *cobra.Command, args []string) {
refreshCatalogue(cmd, numThreads)
},
}
// Define the flag for the command
- cmd.Flags().IntVarP(&numThreads, "threads", "t", 5, "Number of threads to use for fetching game data from the GOG")
+ cmd.Flags().IntVarP(&numThreads, "threads", "t", 10,
+ "Number of worker threads to use for fetching game data [1-20]")
return cmd
}
+// refreshCatalogue updates the game catalogue with the latest data from GOG
+// It takes a cobra.Command and an integer representing the number of threads as arguments.
func refreshCatalogue(cmd *cobra.Command, numThreads int) {
log.Info().Msg("Refreshing the game catalogue...")
@@ -170,28 +195,28 @@ func refreshCatalogue(cmd *cobra.Command, numThreads int) {
}
// Try to refresh the access token
- user, err := authenticateUser(false)
+ token, err := client.RefreshToken()
if err != nil {
- cmd.PrintErrln("Error: Failed to authenticate. Please check your credentials and try again.")
+ cmd.PrintErrln("Error: Failed to refresh the access token. Please login again.")
}
- if user == nil {
- cmd.PrintErrln("Error: No user data found. Please run 'gogg init' to enter your username and password.")
+ if token == nil {
+ cmd.PrintErrln("Error: Failed to refresh the access token. Did you login?")
return
}
- games, err := client.FetchIdOfOwnedGames(user.AccessToken, "https://embed.gog.com/user/data/games")
+ games, err := client.FetchIdOfOwnedGames(token.AccessToken, "https://embed.gog.com/user/data/games")
if err != nil {
if strings.Contains(err.Error(), "401") {
- cmd.PrintErrln("Error: Failed to fetch the list of owned games. Please use `auth` command to re-authenticate.")
+ cmd.PrintErrln("Error: Failed to fetch the list of owned games. Please use `login` command to login.")
}
log.Info().Msgf("Failed to fetch owned games: %v\n", err)
return
} else if len(games) == 0 {
- log.Info().Msg("No games found in your GOG account.")
+ log.Info().Msg("No games found in the GOG account.")
return
} else {
- log.Info().Msgf("Found %d games IDs in your GOG account.\n", len(games))
+ log.Info().Msgf("Found %d games IDs in the GOG account.\n", len(games))
}
if err := db.EmptyCatalogue(); err != nil {
@@ -226,7 +251,7 @@ func refreshCatalogue(cmd *cobra.Command, numThreads int) {
defer wg.Done()
for task := range taskChan {
url := fmt.Sprintf("https://embed.gog.com/account/gameDetails/%d.json", task.gameID)
- task.details, task.rawDetails, task.err = client.FetchGameData(user.AccessToken, url)
+ task.details, task.rawDetails, task.err = client.FetchGameData(token.AccessToken, url)
if task.err != nil {
log.Info().Msgf("Failed to fetch game details for game ID %d: %v\n", task.gameID, task.err)
}
@@ -234,7 +259,8 @@ func refreshCatalogue(cmd *cobra.Command, numThreads int) {
if task.err == nil && task.details.Title != "" {
err = db.PutInGame(task.gameID, task.details.Title, task.rawDetails)
if err != nil {
- log.Info().Msgf("Failed to insert game details for game ID %d: %v in the catalogue\n", task.gameID, err)
+ log.Info().Msgf("Failed to insert game details for game ID %d: %v in the catalogue\n",
+ task.gameID, err)
}
}
_ = bar.Add(1)
@@ -251,45 +277,44 @@ func refreshCatalogue(cmd *cobra.Command, numThreads int) {
wg.Wait()
bar.Finish()
- cmd.Printf("Refreshing completed successfully.")
+ cmd.Println("Refreshed the game catalogue successfully.")
}
-// searchCmd searches for games in the catalogue by ID or title
+// searchCmd searches for games in the catalogue by ID or title.
+// It returns a pointer to the created cobra.Command.
func searchCmd() *cobra.Command {
- var gameID int
- var searchTerm string
+ var searchByIDFlag bool
+
cmd := &cobra.Command{
- Use: "search",
- Short: "Search for games in the catalogue by ID or title",
+ Use: "search [query]",
+ Short: "Search for games in the catalogue",
+ Long: "Search for games in the catalogue given a query string, which can be a term in the title or a game ID",
+ Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
- searchGames(cmd, gameID, searchTerm)
+ query := args[0]
+ searchGames(cmd, query, searchByIDFlag)
},
}
- // Flags for search
- cmd.Flags().IntVarP(&gameID, "id", "i", 0, "ID of the game to search")
- cmd.Flags().StringVarP(&searchTerm, "term", "t", "", "Search term to search for;"+
- " search is case-insensitive and does partial matching of the term with the game title")
+ // Flag to determine the type of search
+ cmd.Flags().BoolVarP(&searchByIDFlag, "id", "i", false,
+ "Search by game ID instead of title? [true, false]")
+
return cmd
}
-func searchGames(cmd *cobra.Command, gameID int, searchTerm string) {
- if gameID == 0 && searchTerm == "" {
- cmd.PrintErrln("Error: one of the flags --id or --term is required. Use `gogg catalogue search -h` for more information.")
- return
- }
-
- // Check not both flags are provided
- if gameID != 0 && searchTerm != "" {
- cmd.PrintErrln("Error: only one of the flags --id or --term is required. Use `gogg catalogue search -h` for more information.")
- return
- }
-
+// searchGames searches for games in the catalogue by ID or title and displays the results in a table.
+// It takes a cobra.Command, a string representing the query, and a boolean indicating whether to search by ID as arguments.
+func searchGames(cmd *cobra.Command, query string, searchByID bool) {
var games []db.Game
var err error
- // Search by game ID
- if gameID != 0 {
+ if searchByID {
+ gameID, err := strconv.Atoi(query)
+ if err != nil {
+ cmd.PrintErrln("Error: Invalid game ID. It must be a number.")
+ return
+ }
log.Info().Msgf("Searching for game with ID=%d", gameID)
game, err := db.GetGameByID(gameID)
if err != nil {
@@ -300,79 +325,68 @@ func searchGames(cmd *cobra.Command, gameID int, searchTerm string) {
if game != nil {
games = append(games, *game)
}
- }
-
- // Search by search term
- if searchTerm != "" {
- log.Info().Msgf("Searching for games with term=%s in its title", searchTerm)
- games, err = db.SearchGamesByName(searchTerm)
+ } else {
+ log.Info().Msgf("Searching for games with term=%s in their title", query)
+ games, err = db.SearchGamesByName(query)
if err != nil {
- log.Error().Err(err).Msgf("Failed to search games with term=%s in its title", searchTerm)
+ log.Error().Err(err).Msgf("Failed to search games with term=%s in their title", query)
cmd.PrintErrln("Error:", err)
return
}
}
- // Check if any games were found
if len(games) == 0 {
- cmd.Printf("No game(s) found matching the search criteria.\n")
+ cmd.Println("No game(s) found matching the query. Please check the search term or ID.")
return
}
- // Display the search results in a table format
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"Row ID", "Game ID", "Title"})
- table.SetColMinWidth(2, 50) // Set minimum width for the Title column
- table.SetAlignment(tablewriter.ALIGN_LEFT) // Align all columns to the left
- table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) // Align headers to the left
- table.SetAutoWrapText(false) // Disable text wrapping in all columns
- table.SetRowLine(false) // Disable row line breaks
+ table.SetColMinWidth(2, 50)
+ table.SetAlignment(tablewriter.ALIGN_LEFT)
+ table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
+ table.SetAutoWrapText(false)
+ table.SetRowLine(false)
for i, game := range games {
table.Append([]string{
- fmt.Sprintf("%d", i+1), // Row ID
- fmt.Sprintf("%d", game.ID), // Game ID
- game.Title, // Game Title
+ fmt.Sprintf("%d", i+1),
+ fmt.Sprintf("%d", game.ID),
+ game.Title,
})
}
table.Render()
}
-// exportCmd exports the game catalogue to a file in JSON or CSV format based on the user's choice
+// exportCmd creates a new cobra.Command for exporting the game catalogue to a file.
+// It returns a pointer to the created cobra.Command.
func exportCmd() *cobra.Command {
- exportPath := ""
- exportFormat := ""
+ var exportFormat string
cmd := &cobra.Command{
- Use: "export",
+ Use: "export [exportDir]",
Short: "Export the game catalogue to a file",
+ Long: "Export the game catalogue to a file in the specified path in the specified format",
+ Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
+ exportPath := args[0]
exportCatalogue(cmd, exportPath, exportFormat)
},
}
- // Add flags for export path and format
- cmd.Flags().StringVarP(&exportPath, "dir", "d", "", "Directory to export the file (required)")
- cmd.Flags().StringVarP(&exportFormat, "format", "f", "", "Export format: json or csv (required)")
-
- // Mark flags as required
- cmd.MarkFlagRequired("path")
- cmd.MarkFlagRequired("format")
+ // Add flag for export format
+ cmd.Flags().StringVarP(&exportFormat, "format", "f", "csv",
+ "Format of the exported file [csv, json]")
return cmd
}
+// exportCatalogue handles the export logic for the game catalogue.
+// It takes a cobra.Command, a string representing the export path, and a string representing the export format as arguments.
func exportCatalogue(cmd *cobra.Command, exportPath, exportFormat string) {
log.Info().Msg("Exporting the game catalogue...")
- // Validate the export path
- if exportPath == "" {
- log.Error().Msg("Export path is required.")
- cmd.PrintErrln("Error: Export path is required.")
- return
- }
-
// Ensure the directory exists or create it
if err := os.MkdirAll(exportPath, os.ModePerm); err != nil {
log.Error().Err(err).Msg("Failed to create export directory.")
@@ -417,6 +431,7 @@ func exportCatalogue(cmd *cobra.Command, exportPath, exportFormat string) {
}
// exportCatalogueToCSV exports the game catalogue to a CSV file.
+// It takes a string representing the file path as an argument and returns an error if any.
func exportCatalogueToCSV(path string) error {
// Fetch all games from the catalogue
@@ -457,6 +472,7 @@ func exportCatalogueToCSV(path string) error {
}
// exportCatalogueToJSON exports the game catalogue to a JSON file.
+// It takes a string representing the file path as an argument and returns an error if any.
func exportCatalogueToJSON(path string) error {
// Fetch all games from the catalogue
diff --git a/cmd/cli.go b/cmd/cli.go
index 7d41b01..8a9d1e5 100644
--- a/cmd/cli.go
+++ b/cmd/cli.go
@@ -7,14 +7,15 @@ import (
"os"
)
-// Execute runs the root command of Gogg
+// Execute runs the root command of Gogg.
+// It initializes the database, sets up the root command, and executes it.
func Execute() {
rootCmd := createRootCmd()
initializeDatabase()
defer closeDatabase()
- // Add a global flags to the root command
- rootCmd.PersistentFlags().BoolP("help", "h", false, "help for `gogg` and its commands")
+ // Add a global flag to the root command
+ rootCmd.PersistentFlags().BoolP("help", "h", false, "Show help for the command")
// Execute the root command
if err := rootCmd.Execute(); err != nil {
@@ -23,21 +24,22 @@ func Execute() {
}
}
-// createRootCmd defines the root command and adds subcommands
+// createRootCmd defines the root command and adds subcommands.
+// It returns a pointer to the created cobra.Command.
func createRootCmd() *cobra.Command {
rootCmd := &cobra.Command{
Use: "gogg",
Short: "A downloader for GOG",
- Long: "Gogg is a minimalistic command-line tool to download games from GOG.com",
+ Long: "Gogg is a minimalistic command-line tool to download games files from GOG",
}
// Add subcommands to the root command
rootCmd.AddCommand(
- initCmd(),
- authCmd(),
catalogueCmd(),
downloadCmd(),
versionCmd(),
+ loginCmd(),
+ fileCmd(),
)
// Hide the completion command and replace the help command
@@ -50,7 +52,8 @@ func createRootCmd() *cobra.Command {
return rootCmd
}
-// initializeDatabase initializes Gogg's internal database
+// initializeDatabase initializes Gogg's internal database.
+// It logs an error and exits the program if the database initialization fails.
func initializeDatabase() {
if err := db.InitDB(); err != nil {
log.Info().Msgf("Failed to initialize database: %v\n", err)
@@ -58,7 +61,8 @@ func initializeDatabase() {
}
}
-// closeDatabase closes the database connection
+// closeDatabase closes the database connection.
+// It logs an error and exits the program if closing the database fails.
func closeDatabase() {
if err := db.CloseDB(); err != nil {
log.Error().Err(err).Msg("Failed to close the database.")
diff --git a/cmd/download.go b/cmd/download.go
index 64a1a06..ca574d0 100644
--- a/cmd/download.go
+++ b/cmd/download.go
@@ -7,6 +7,8 @@ import (
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"os"
+ "path/filepath"
+ "strconv"
)
// Map of supported game languages and their native names
@@ -24,49 +26,49 @@ var gameLanguages = map[string]string{
"ko": "한국어", // Korean
}
-// downloadCmd downloads a selected game from GOG
+// downloadCmd creates a new cobra.Command for downloading a selected game from GOG.
+// It returns a pointer to the created cobra.Command.
func downloadCmd() *cobra.Command {
- var gameID int
- var downloadDir string
var language string
var platformName string
var extrasFlag bool
var dlcFlag bool
var resumeFlag bool
var numThreads int
+ var flattenFlag bool
cmd := &cobra.Command{
- Use: "download",
+ Use: "download [gameID] [downloadDir]",
Short: "Download game files from GOG",
+ Long: "Download game files from GOG for the specified game ID to the specified directory",
+ Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
- executeDownload(gameID, downloadDir, language, platformName, extrasFlag, dlcFlag, resumeFlag, numThreads)
+ gameID, err := strconv.Atoi(args[0])
+ if err != nil {
+ cmd.PrintErrln("Error: Invalid game ID. It must be a positive integer.")
+ return
+ }
+ downloadDir := args[1]
+ executeDownload(gameID, downloadDir, language, platformName, extrasFlag, dlcFlag, resumeFlag, flattenFlag, numThreads)
},
}
// Add flags for download options
- cmd.Flags().IntVarP(&gameID, "id", "i", 0, "ID of the game to download (required)")
- cmd.Flags().StringVarP(&downloadDir, "dir", "o", "", "Directory to store the downloaded files (required)")
cmd.Flags().StringVarP(&language, "lang", "l", "en", "Game language [en, fr, de, es, it, ru, pl, pt-BR, zh-Hans, ja, ko]")
cmd.Flags().StringVarP(&platformName, "platform", "p", "windows", "Platform name [all, windows, mac, linux]; all means all platforms")
- cmd.Flags().BoolVarP(&extrasFlag, "extras", "e", true, "Include extras? [true, false]")
- cmd.Flags().BoolVarP(&dlcFlag, "dlcs", "d", true, "Include DLC? [true, false]")
+ cmd.Flags().BoolVarP(&extrasFlag, "extras", "e", true, "Include extra content files? [true, false]")
+ cmd.Flags().BoolVarP(&dlcFlag, "dlcs", "d", true, "Include DLC files? [true, false]")
cmd.Flags().BoolVarP(&resumeFlag, "resume", "r", true, "Resume downloading? [true, false]")
- cmd.Flags().IntVarP(&numThreads, "threads", "t", 5, "Number of threads to use for downloading (default: 5)")
-
- // Game ID flag and download path are required
- if err := cmd.MarkFlagRequired("id"); err != nil {
- log.Error().Err(err).Msg("Failed to mark 'id' flag as required.")
- }
-
- if err := cmd.MarkFlagRequired("path"); err != nil {
- log.Error().Err(err).Msg("Failed to mark 'path' flag as required.")
- }
+ cmd.Flags().IntVarP(&numThreads, "threads", "t", 5, "Number of worker threads to use for downloading [1-20]")
+ cmd.Flags().BoolVarP(&flattenFlag, "flatten", "f", true, "Flatten the directory structure when downloading? [true, false]")
return cmd
}
-// executeDownload handles the download logic
-func executeDownload(gameID int, downloadPath, language, platformName string, extrasFlag, dlcFlag, resumeFlag bool, numThreads int) {
+// executeDownload handles the download logic for a specified game.
+// It takes the game ID, download path, language, platform name, and various flags as parameters.
+func executeDownload(gameID int, downloadPath, language, platformName string, extrasFlag, dlcFlag, resumeFlag bool,
+ flattenFlag bool, numThreads int) {
log.Info().Msgf("Downloading games to %s...\n", downloadPath)
log.Info().Msgf("Language: %s, Platform: %s, Extras: %v, DLC: %v\n", language, platformName, extrasFlag, dlcFlag)
@@ -77,8 +79,8 @@ func executeDownload(gameID int, downloadPath, language, platformName string, ex
}
// Try to refresh the access token
- if _, err := authenticateUser(false); err != nil {
- log.Error().Msg("Failed to refresh the access token.")
+ if _, err := client.RefreshToken(); err != nil {
+ log.Error().Msg("Failed to refresh the access token. Please login again.")
return
}
@@ -109,7 +111,7 @@ func executeDownload(gameID int, downloadPath, language, platformName string, ex
}
// Load the user data
- user, err := db.GetUserData()
+ user, err := db.GetTokenRecord()
if err != nil {
log.Error().Err(err).Msg("Failed to retrieve user data from the database.")
return
@@ -117,24 +119,27 @@ func executeDownload(gameID int, downloadPath, language, platformName string, ex
// Show download parameters to the user
logDownloadParameters(parsedGameData, gameID, downloadPath, language, platformName, extrasFlag, dlcFlag, resumeFlag,
- numThreads)
+ flattenFlag, numThreads)
// Download the game files
err = client.DownloadGameFiles(user.AccessToken, parsedGameData, downloadPath, gameLanguages[language],
- platformName, extrasFlag, dlcFlag, resumeFlag, numThreads)
+ platformName, extrasFlag, dlcFlag, resumeFlag, flattenFlag, numThreads)
if err != nil {
log.Error().Err(err).Msg("Failed to download game files.")
}
- fmt.Println("\rGame files downloaded successfully.")
+ fmt.Printf("\rGame files downloaded successfully to: \"%s\"\n", filepath.Join(downloadPath,
+ client.SanitizePath(parsedGameData.Title)))
}
-// logDownloadParameters logs the download parameters
+// logDownloadParameters logs the download parameters to the console.
+// It takes the game object, game ID, download path, language, platform name, and various flags as parameters.
func logDownloadParameters(game client.Game, gameID int, downloadPath, language, platformName string,
- extrasFlag, dlcFlag, resumeFlag bool, numThreads int) {
+ extrasFlag, dlcFlag, resumeFlag bool, flattenFlag bool, numThreads int) {
fmt.Println("================================= Download Parameters =====================================")
fmt.Printf("Downloading \"%v\" (with game ID=\"%d\") to \"%v\"\n", game.Title, gameID, downloadPath)
fmt.Printf("Platform: \"%v\", Language: '%v'\n", platformName, gameLanguages[language])
fmt.Printf("Include Extras: \"%v, Include DLCs: \"%v\", Resume enabled: \"%v\"\n", extrasFlag, dlcFlag, resumeFlag)
fmt.Printf("Number of worker threads for download: \"%d\"\n", numThreads)
+ fmt.Printf("Flatten directory structure: \"%v\"\n", flattenFlag)
fmt.Println("============================================================================================")
}
diff --git a/cmd/file.go b/cmd/file.go
new file mode 100644
index 0000000..897c582
--- /dev/null
+++ b/cmd/file.go
@@ -0,0 +1,236 @@
+package cmd
+
+import (
+ "crypto/md5"
+ "crypto/sha1"
+ "crypto/sha256"
+ "crypto/sha512"
+ "encoding/hex"
+ "fmt"
+ "github.com/rs/zerolog/log"
+ "github.com/spf13/cobra"
+ "hash"
+ "io"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+)
+
+var hashAlgorithms = []string{"md5", "sha1", "sha256", "sha512"}
+
+// fileCmd represents the file command
+// It returns a cobra.Command that performs various file operations.
+func fileCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "file",
+ Short: "Perform various file operations",
+ }
+
+ // Add subcommands to the file command
+ cmd.AddCommand(hashCmd())
+
+ return cmd
+}
+
+// hashCmd represents the hash command
+// It returns a cobra.Command that generates hash values for files in a directory.
+func hashCmd() *cobra.Command {
+ var saveToFileFlag bool
+ var cleanFlag bool
+ var algo string
+ var recursiveFlag bool
+
+ cmd := &cobra.Command{
+ Use: "hash [fileDir]",
+ Short: "Generate hash values for game files in a directory",
+ Args: cobra.ExactArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ dir := args[0]
+ // Validate the hash algorithm
+ if !isValidHashAlgo(algo) {
+ log.Error().Msgf("Unsupported hash algorithm: %s", algo)
+ return
+ }
+
+ // Call the function to generate hash files
+ generateHashFiles(dir, algo, recursiveFlag, saveToFileFlag, cleanFlag)
+ },
+ }
+
+ // Add flags for hash options
+ cmd.Flags().StringVarP(&algo, "algo", "a", "sha256", "Hash algorithm to use [md5, sha1, sha256, sha512]")
+ cmd.Flags().BoolVarP(&recursiveFlag, "recursive", "r", true, "Process files in subdirectories? [true, false]")
+ cmd.Flags().BoolVarP(&saveToFileFlag, "save", "s", false, "Save hash to files? [true, false]")
+ cmd.Flags().BoolVarP(&cleanFlag, "clean", "c", false, "Remove old hash files before generating new ones? [true, false]")
+
+ return cmd
+}
+
+// isValidHashAlgo checks if the specified hash algorithm is supported.
+// It takes a string representing the algorithm and returns a boolean.
+func isValidHashAlgo(algo string) bool {
+ for _, validAlgo := range hashAlgorithms {
+ if strings.ToLower(algo) == validAlgo {
+ return true
+ }
+ }
+ return false
+}
+
+// removeHashFiles removes old hash files from the directory.
+// It takes a string representing the directory and a boolean indicating whether to process subdirectories.
+func removeHashFiles(dir string, recursive bool) {
+ err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ log.Error().Msgf("Error accessing path %q: %v", path, err)
+ return err
+ }
+
+ // Skip directories if not recursive
+ if info.IsDir() && !recursive {
+ return filepath.SkipDir
+ }
+
+ // Remove hash files of all supported algorithms
+ for _, algo := range hashAlgorithms {
+ if strings.HasSuffix(path, "."+algo) {
+ if err := os.Remove(path); err != nil {
+ log.Error().Msgf("Error removing hash file %s: %v", path, err)
+ }
+ }
+ }
+ return nil
+ })
+
+ if err != nil {
+ log.Error().Msgf("Error removing hash files: %v", err)
+ } else {
+ log.Info().Msgf("Removed old hash files from %s", dir)
+ }
+}
+
+// generateHashFiles generates hash files for files in a directory using the specified hash algorithm.
+// It takes a string representing the directory, a string representing the algorithm, a boolean indicating whether to process subdirectories,
+// a boolean indicating whether to save the hash to files, and a boolean indicating whether to remove old hash files before generating new ones.
+func generateHashFiles(dir string, algo string, recursive bool, saveToFile bool, clean bool) {
+ exclusionList := []string{".git", ".gitignore", ".DS_Store", "Thumbs.db",
+ "desktop.ini", "*.json", "*.xml", "*.csv", "*.log", "*.txt", "*.md", "*.html", "*.htm",
+ "*.md5", "*.sha1", "*.sha256", "*.sha512", "*.cksum", "*.sum", "*.sig", "*.asc", "*.gpg"}
+
+ var hashFiles []string
+ var wg sync.WaitGroup
+ fileChan := make(chan string)
+
+ // Determine the number of workers
+ numWorkers := runtime.NumCPU() - 2
+ if numWorkers < 2 {
+ numWorkers = 2
+ }
+
+ // Remove old hash files if clean flag is set
+ if clean {
+ log.Info().Msgf("Cleaning old hash files from %s and its subdirectories", dir)
+ removeHashFiles(dir, true)
+ }
+
+ // Walk through the directory
+ go func() {
+ filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ log.Error().Msgf("Error accessing path %q: %v", path, err)
+ return err
+ }
+
+ // Skip directories if not recursive
+ if info.IsDir() {
+ if path != dir && !recursive {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+
+ // Skip excluded files
+ for _, pattern := range exclusionList {
+ matched, _ := filepath.Match(pattern, info.Name())
+ if matched {
+ return nil
+ }
+ }
+
+ // Send file path to channel
+ fileChan <- path
+ return nil
+ })
+ close(fileChan)
+ }()
+
+ for i := 0; i < numWorkers; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for path := range fileChan {
+ // Generate hash for the file
+ hash, err := generateHash(path, algo)
+ if err != nil {
+ log.Error().Msgf("Error generating hash for file %s: %v", path, err)
+ continue
+ }
+
+ if saveToFile {
+ // Write the hash to a file with .algo-name extension
+ hashFilePath := path + "." + algo
+ err = os.WriteFile(hashFilePath, []byte(hash), 0644)
+ if err != nil {
+ log.Error().Msgf("Error writing hash to file %s: %v", hashFilePath, err)
+ continue
+ }
+ hashFiles = append(hashFiles, hashFilePath)
+ } else {
+ // Print the hash value
+ fmt.Printf("%s hash for \"%s\": %s\n", algo, path, hash)
+ }
+ }
+ }()
+ }
+
+ wg.Wait()
+
+ if saveToFile {
+ fmt.Println("Generated hash files:")
+ for _, file := range hashFiles {
+ fmt.Println(file)
+ }
+ }
+}
+
+// generateHash generates the hash for a given file using the specified algorithm.
+// It takes a string representing the file path and a string representing the algorithm, and returns the hash as a string and an error if any.
+func generateHash(filePath string, algo string) (string, error) {
+ file, err := os.Open(filePath)
+ if err != nil {
+ return "", err
+ }
+ defer file.Close()
+
+ var hashAlgo hash.Hash
+ switch strings.ToLower(algo) {
+ case "md5":
+ hashAlgo = md5.New()
+ case "sha1":
+ hashAlgo = sha1.New()
+ case "sha256":
+ hashAlgo = sha256.New()
+ case "sha512":
+ hashAlgo = sha512.New()
+ default:
+ return "", fmt.Errorf("unsupported hash algorithm: %s", algo)
+ }
+
+ if _, err := io.Copy(hashAlgo, file); err != nil {
+ return "", err
+ }
+
+ return hex.EncodeToString(hashAlgo.Sum(nil)), nil
+}
diff --git a/cmd/init.go b/cmd/login.go
similarity index 64%
rename from cmd/init.go
rename to cmd/login.go
index b1a3856..4f8322f 100644
--- a/cmd/init.go
+++ b/cmd/login.go
@@ -10,23 +10,26 @@ import (
"strings"
)
-// initCmd initializes Gogg for first-time use by saving the user credentials in the internal database.
-func initCmd() *cobra.Command {
+// loginCmd creates a new cobra.Command for logging into GOG.com.
+// It returns a pointer to the created cobra.Command.
+func loginCmd() *cobra.Command {
var gogUsername, gogPassword string
+ var headless bool
cmd := &cobra.Command{
- Use: "init",
- Short: "Initialize Gogg for first-time use",
+ Use: "login",
+ Short: "Login to GOG.com",
+ Long: "Login to GOG.com using your username and password",
Run: func(cmd *cobra.Command, args []string) {
cmd.Println("Please enter your GOG username and password.")
gogUsername = promptForInput("GOG username: ")
gogPassword = promptForPassword("GOG password: ")
if validateCredentials(gogUsername, gogPassword) {
- if err := client.SaveUserCredentials(gogUsername, gogPassword); err != nil {
- cmd.PrintErrln("Error: Failed to save the credentials.")
+ if err := client.Login(client.GOGLoginURL, gogUsername, gogPassword, headless); err != nil {
+ cmd.PrintErrln("Error: Failed to login to GOG.com.")
} else {
- cmd.Println("Credentials saved successfully.")
+ cmd.Println("Login was successful.")
}
} else {
cmd.PrintErrln("Error: Username and password cannot be empty.")
@@ -34,10 +37,14 @@ func initCmd() *cobra.Command {
},
}
+ // Add flags for login options
+ cmd.Flags().BoolVarP(&headless, "headless", "n", true, "Login in headless mode without showing the browser window? [true, false]")
+
return cmd
}
// promptForInput prompts the user for input and returns the trimmed string.
+// It takes a prompt string as an argument.
func promptForInput(prompt string) string {
reader := bufio.NewReader(os.Stdin)
fmt.Print(prompt)
@@ -50,6 +57,7 @@ func promptForInput(prompt string) string {
}
// promptForPassword prompts the user for a password securely and returns the trimmed string.
+// It takes a prompt string as an argument.
func promptForPassword(prompt string) string {
fmt.Print(prompt)
password, err := term.ReadPassword(int(os.Stdin.Fd()))
@@ -62,6 +70,7 @@ func promptForPassword(prompt string) string {
}
// validateCredentials checks if the username and password are not empty.
+// It takes the username and password strings as arguments and returns a boolean.
func validateCredentials(username, password string) bool {
return username != "" && password != ""
}
diff --git a/cmd/shared.go b/cmd/shared.go
deleted file mode 100644
index 743afed..0000000
--- a/cmd/shared.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package cmd
-
-import (
- "fmt"
- "github.com/habedi/gogg/client"
- "github.com/habedi/gogg/db"
- "github.com/rs/zerolog/log"
-)
-
-// authenticateUser handles the authentication process
-func authenticateUser(showWindow bool) (*db.User, error) {
- user, err := db.GetUserData()
- if err != nil {
- fmt.Printf("Error: %v\n", err)
- return nil, err
- }
-
- if err := client.AuthGOG(authURL, user, !showWindow); err != nil {
- log.Error().Err(err).Msg("Failed to authenticate with GOG.com.")
- return nil, err
- }
-
- return user, nil
-}
diff --git a/cmd/version.go b/cmd/version.go
index 8599137..b3b7257 100644
--- a/cmd/version.go
+++ b/cmd/version.go
@@ -5,16 +5,18 @@ import (
)
var (
- // Gogg version
- version = "0.2.1"
+ // version holds the current version of the Gogg.
+ version = "0.3.0"
)
-// versionCmd shows the version of Gogg
+// versionCmd creates a new cobra.Command that shows the version of Gogg.
+// It returns a pointer to the created cobra.Command.
func versionCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "version",
Short: "Show version information",
Run: func(cmd *cobra.Command, args []string) {
+ // Print the current version of Gogg to the command line.
cmd.Println("Gogg version:", version)
},
}
diff --git a/db/db.go b/db/db.go
index 9a0ae25..31e6938 100644
--- a/db/db.go
+++ b/db/db.go
@@ -15,6 +15,7 @@ var (
)
// InitDB initializes the database and creates the tables if they don't exist.
+// It returns an error if any step in the initialization process fails.
func InitDB() error {
if err := createDBDirectory(); err != nil {
return err
@@ -35,6 +36,7 @@ func InitDB() error {
}
// createDBDirectory checks if the database path exists and creates it if it doesn't.
+// It returns an error if the directory creation fails.
func createDBDirectory() error {
if _, err := os.Stat(filepath.Dir(Path)); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(Path), 0755); err != nil {
@@ -46,6 +48,7 @@ func createDBDirectory() error {
}
// openDatabase opens the database connection.
+// It returns an error if the database connection fails to open.
func openDatabase() error {
var err error
Db, err = gorm.Open(sqlite.Open(Path), &gorm.Config{})
@@ -57,20 +60,22 @@ func openDatabase() error {
}
// migrateTables creates the tables if they don't exist.
+// It returns an error if the table migration fails.
func migrateTables() error {
if err := Db.AutoMigrate(&Game{}); err != nil {
log.Error().Err(err).Msg("Failed to auto-migrate database")
return err
}
- if err := Db.AutoMigrate(&User{}); err != nil {
+ if err := Db.AutoMigrate(&Token{}); err != nil {
log.Error().Err(err).Msg("Failed to auto-migrate database")
return err
}
return nil
}
-// configureLogger configures the GORM logger based on environment variable.
+// configureLogger configures the GORM logger based on the environment variable.
+// It sets the logger to silent mode if the DEBUG_GOGG environment variable is not set, otherwise it sets it to debug mode.
func configureLogger() {
if os.Getenv("DEBUG_GOGG") == "" {
Db.Logger = Db.Logger.LogMode(0) // Silent mode
@@ -80,6 +85,7 @@ func configureLogger() {
}
// CloseDB closes the database connection.
+// It returns an error if the database connection fails to close.
func CloseDB() error {
sqlDB, err := Db.DB()
if err != nil {
diff --git a/db/db_test.go b/db/db_test.go
index 230c19d..2fa13ba 100644
--- a/db/db_test.go
+++ b/db/db_test.go
@@ -8,6 +8,8 @@ import (
"testing"
)
+// TestInitDB tests the initialization of the database.
+// It sets up a temporary directory, initializes the database, and checks if the database file is created successfully.
func TestInitDB(t *testing.T) {
tempDir := t.TempDir()
os.Setenv("HOME", tempDir)
@@ -24,6 +26,8 @@ func TestInitDB(t *testing.T) {
assert.NoError(t, closeErr, "CloseDB should not return an error")
}
+// TestCloseDB tests the closing of the database connection.
+// It ensures that the CloseDB function does not return an error.
func TestCloseDB(t *testing.T) {
err := db.CloseDB()
assert.NoError(t, err, "CloseDB should not return an error")
diff --git a/db/game.go b/db/game.go
index 2913aaa..34c5d5c 100644
--- a/db/game.go
+++ b/db/game.go
@@ -16,6 +16,7 @@ type Game struct {
}
// PutInGame inserts or updates a game record in the catalogue.
+// It takes the game ID, title, and data as parameters and returns an error if the operation fails.
func PutInGame(id int, title, data string) error {
game := Game{
ID: id,
@@ -27,6 +28,7 @@ func PutInGame(id int, title, data string) error {
}
// upsertGame performs an upsert operation on the game record.
+// It takes a Game object as a parameter and returns an error if the operation fails.
func upsertGame(game Game) error {
if err := Db.Clauses(
clause.OnConflict{
@@ -42,6 +44,7 @@ func upsertGame(game Game) error {
}
// EmptyCatalogue removes all records from the game catalogue.
+// It returns an error if the operation fails.
func EmptyCatalogue() error {
if err := Db.Unscoped().Where("1 = 1").Delete(&Game{}).Error; err != nil {
log.Error().Err(err).Msg("Failed to empty game catalogue")
@@ -53,6 +56,7 @@ func EmptyCatalogue() error {
}
// GetCatalogue retrieves all games in the catalogue.
+// It returns a slice of Game objects and an error if the operation fails.
func GetCatalogue() ([]Game, error) {
var games []Game
if err := Db.Find(&games).Error; err != nil {
@@ -65,6 +69,7 @@ func GetCatalogue() ([]Game, error) {
}
// GetGameByID retrieves a game from the catalogue by its ID.
+// It takes the game ID as a parameter and returns a pointer to the Game object and an error if the operation fails.
func GetGameByID(id int) (*Game, error) {
if Db == nil {
return nil, fmt.Errorf("database connection is not initialized")
@@ -82,6 +87,7 @@ func GetGameByID(id int) (*Game, error) {
}
// SearchGamesByName searches for games in the catalogue by name.
+// It takes the game name as a parameter and returns a slice of Game objects and an error if the operation fails.
func SearchGamesByName(name string) ([]Game, error) {
if Db == nil {
return nil, fmt.Errorf("database connection is not initialized")
diff --git a/db/game_test.go b/db/game_test.go
index 853688a..7c1a06a 100644
--- a/db/game_test.go
+++ b/db/game_test.go
@@ -9,6 +9,8 @@ import (
"testing"
)
+// setupTestDBForGames sets up an in-memory SQLite database for testing purposes.
+// It returns a pointer to the gorm.DB instance.
func setupTestDBForGames(t *testing.T) *gorm.DB {
dBOject, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
@@ -16,6 +18,7 @@ func setupTestDBForGames(t *testing.T) *gorm.DB {
return dBOject
}
+// TestPutInGame_InsertsNewGame tests the insertion of a new game into the database.
func TestPutInGame_InsertsNewGame(t *testing.T) {
testDB := setupTestDBForGames(t)
db.Db = testDB
@@ -30,6 +33,7 @@ func TestPutInGame_InsertsNewGame(t *testing.T) {
assert.Equal(t, "Test Data", game.Data)
}
+// TestPutInGame_UpdatesExistingGame tests the update of an existing game in the database.
func TestPutInGame_UpdatesExistingGame(t *testing.T) {
testDB := setupTestDBForGames(t)
db.Db = testDB
@@ -47,6 +51,7 @@ func TestPutInGame_UpdatesExistingGame(t *testing.T) {
assert.Equal(t, "Updated Data", game.Data)
}
+// TestEmptyCatalogue_RemovesAllGames tests the removal of all games from the database.
func TestEmptyCatalogue_RemovesAllGames(t *testing.T) {
testDB := setupTestDBForGames(t)
db.Db = testDB
@@ -63,6 +68,7 @@ func TestEmptyCatalogue_RemovesAllGames(t *testing.T) {
assert.Empty(t, games)
}
+// TestGetCatalogue_ReturnsAllGames tests the retrieval of all games from the database.
func TestGetCatalogue_ReturnsAllGames(t *testing.T) {
testDB := setupTestDBForGames(t)
db.Db = testDB
@@ -77,6 +83,7 @@ func TestGetCatalogue_ReturnsAllGames(t *testing.T) {
assert.Len(t, games, 2)
}
+// TestGetGameByID_ReturnsGame tests the retrieval of a game by its ID from the database.
func TestGetGameByID_ReturnsGame(t *testing.T) {
testDB := setupTestDBForGames(t)
db.Db = testDB
@@ -91,6 +98,7 @@ func TestGetGameByID_ReturnsGame(t *testing.T) {
assert.Equal(t, "Test Data", game.Data)
}
+// TestGetGameByID_ReturnsNilForNonExistentGame tests that a non-existent game returns nil.
func TestGetGameByID_ReturnsNilForNonExistentGame(t *testing.T) {
testDB := setupTestDBForGames(t)
db.Db = testDB
@@ -100,6 +108,7 @@ func TestGetGameByID_ReturnsNilForNonExistentGame(t *testing.T) {
assert.Nil(t, game)
}
+// TestSearchGamesByName_ReturnsMatchingGames tests the search functionality for games by name.
func TestSearchGamesByName_ReturnsMatchingGames(t *testing.T) {
testDB := setupTestDBForGames(t)
db.Db = testDB
@@ -115,6 +124,7 @@ func TestSearchGamesByName_ReturnsMatchingGames(t *testing.T) {
assert.Equal(t, "Test Game 1", games[0].Title)
}
+// TestSearchGamesByName_ReturnsEmptyForNoMatches tests that no matches return an empty result.
func TestSearchGamesByName_ReturnsEmptyForNoMatches(t *testing.T) {
testDB := setupTestDBForGames(t)
db.Db = testDB
diff --git a/db/token.go b/db/token.go
new file mode 100644
index 0000000..79935ee
--- /dev/null
+++ b/db/token.go
@@ -0,0 +1,75 @@
+package db
+
+import (
+ "errors"
+ "fmt"
+ "github.com/rs/zerolog/log"
+ "gorm.io/gorm"
+)
+
+// Token represents the user's authentication token data.
+type Token struct {
+ AccessToken string `json:"access_token,omitempty"`
+ RefreshToken string `json:"refresh_token,omitempty"`
+ ExpiresAt string `json:"expires_at,omitempty"`
+}
+
+// GetTokenRecord retrieves the token record from the database.
+// It returns a pointer to the Token object and an error if the operation fails.
+func GetTokenRecord() (*Token, error) {
+ if Db == nil {
+ return nil, fmt.Errorf("database connection is not initialized")
+ }
+
+ var token Token
+ if err := Db.First(&token).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil // Token not found
+ }
+ log.Error().Err(err).Msg("Failed to retrieve token data")
+ return nil, err
+ }
+
+ if &token == nil {
+ return nil, fmt.Errorf("no token data found. Please try logging in first")
+ }
+
+ return &token, nil
+}
+
+// UpsertTokenRecord inserts or updates the token record in the database.
+// It takes a pointer to the Token object as a parameter and returns an error if the operation fails.
+func UpsertTokenRecord(token *Token) error {
+ if Db == nil {
+ return fmt.Errorf("database connection is not initialized")
+ }
+
+ var existingToken Token
+ err := Db.First(&existingToken).Error
+ if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+ log.Error().Err(err).Msg("Failed to check existing token")
+ return err
+ }
+
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ // Insert new token
+ if err := Db.Create(token).Error; err != nil {
+ log.Error().Err(err).Msgf("Failed to insert new token: %s", token.AccessToken[:10])
+ return err
+ }
+ log.Info().Msgf("Token inserted successfully: %s", token.AccessToken[:10])
+ } else {
+ // Update existing token
+ if err := Db.Model(&existingToken).Where("1 = 1").Updates(Token{
+ AccessToken: token.AccessToken,
+ RefreshToken: token.RefreshToken,
+ ExpiresAt: token.ExpiresAt,
+ }).Error; err != nil {
+ log.Error().Err(err).Msgf("Failed to update token: %s", token.AccessToken[:10])
+ return err
+ }
+ log.Info().Msgf("Token updated successfully: %s", token.AccessToken[:10])
+ }
+
+ return nil
+}
diff --git a/db/token_test.go b/db/token_test.go
new file mode 100644
index 0000000..8fa967f
--- /dev/null
+++ b/db/token_test.go
@@ -0,0 +1,102 @@
+package db_test
+
+import (
+ "github.com/habedi/gogg/db"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+ "testing"
+)
+
+// setupTestDBForToken sets up an in-memory SQLite database for testing purposes.
+// It returns a pointer to the gorm.DB instance.
+func setupTestDBForToken(t *testing.T) *gorm.DB {
+ dBOject, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, dBOject.AutoMigrate(&db.Token{}))
+ return dBOject
+}
+
+// TestGetTokenRecord_ReturnsToken tests the retrieval of a token record from the database.
+func TestGetTokenRecord_ReturnsToken(t *testing.T) {
+ testDB := setupTestDBForToken(t)
+ db.Db = testDB
+
+ token := &db.Token{AccessToken: "access_token", RefreshToken: "refresh_token", ExpiresAt: "expires_at"}
+ err := db.UpsertTokenRecord(token)
+ require.NoError(t, err)
+
+ retrievedToken, err := db.GetTokenRecord()
+ require.NoError(t, err)
+ assert.NotNil(t, retrievedToken)
+ assert.Equal(t, "access_token", retrievedToken.AccessToken)
+ assert.Equal(t, "refresh_token", retrievedToken.RefreshToken)
+ assert.Equal(t, "expires_at", retrievedToken.ExpiresAt)
+}
+
+// TestGetTokenRecord_ReturnsNilForNoToken tests that GetTokenRecord returns nil when no token is found.
+func TestGetTokenRecord_ReturnsNilForNoToken(t *testing.T) {
+ testDB := setupTestDBForToken(t)
+ db.Db = testDB
+
+ retrievedToken, err := db.GetTokenRecord()
+ require.NoError(t, err)
+ assert.Nil(t, retrievedToken)
+}
+
+// TestGetTokenRecord_ReturnsErrorForUninitializedDB tests that GetTokenRecord returns an error when the database is not initialized.
+func TestGetTokenRecord_ReturnsErrorForUninitializedDB(t *testing.T) {
+ db.Db = nil
+
+ retrievedToken, err := db.GetTokenRecord()
+ assert.Error(t, err)
+ assert.Nil(t, retrievedToken)
+}
+
+// TestUpsertTokenRecord_InsertsNewToken tests the insertion of a new token record into the database.
+func TestUpsertTokenRecord_InsertsNewToken(t *testing.T) {
+ testDB := setupTestDBForToken(t)
+ db.Db = testDB
+
+ token := &db.Token{AccessToken: "access_token", RefreshToken: "refresh_token", ExpiresAt: "expires_at"}
+ err := db.UpsertTokenRecord(token)
+ require.NoError(t, err)
+
+ var retrievedToken db.Token
+ err = testDB.First(&retrievedToken, "1 = 1").Error
+ require.NoError(t, err)
+ assert.Equal(t, "access_token", retrievedToken.AccessToken)
+ assert.Equal(t, "refresh_token", retrievedToken.RefreshToken)
+ assert.Equal(t, "expires_at", retrievedToken.ExpiresAt)
+}
+
+// TestUpsertTokenRecord_UpdatesExistingToken tests the update of an existing token record in the database.
+func TestUpsertTokenRecord_UpdatesExistingToken(t *testing.T) {
+ testDB := setupTestDBForToken(t)
+ db.Db = testDB
+
+ token := &db.Token{AccessToken: "access_token", RefreshToken: "refresh_token", ExpiresAt: "expires_at"}
+ err := db.UpsertTokenRecord(token)
+ require.NoError(t, err)
+
+ updatedToken := &db.Token{AccessToken: "new_access_token", RefreshToken: "new_refresh_token", ExpiresAt: "new_expires_at"}
+ err = db.UpsertTokenRecord(updatedToken)
+ require.NoError(t, err)
+
+ var retrievedToken db.Token
+ err = testDB.First(&retrievedToken, "1 = 1").Error
+ require.NoError(t, err)
+ assert.Equal(t, "new_access_token", retrievedToken.AccessToken)
+ assert.Equal(t, "new_refresh_token", retrievedToken.RefreshToken)
+ assert.Equal(t, "new_expires_at", retrievedToken.ExpiresAt)
+}
+
+// TestUpsertTokenRecord_ReturnsErrorForUninitializedDB tests that UpsertTokenRecord returns an error when the database is not initialized.
+func TestUpsertTokenRecord_ReturnsErrorForUninitializedDB(t *testing.T) {
+ db.Db = nil
+
+ token := &db.Token{AccessToken: "access_token", RefreshToken: "refresh_token", ExpiresAt: "expires_at"}
+ err := db.UpsertTokenRecord(token)
+ assert.Error(t, err)
+}
diff --git a/db/user.go b/db/user.go
deleted file mode 100644
index feb38ea..0000000
--- a/db/user.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package db
-
-import (
- "errors"
- "fmt"
- "github.com/rs/zerolog/log"
- "gorm.io/gorm"
- "gorm.io/gorm/clause"
-)
-
-// User holds the user data.
-type User struct {
- Username string `gorm:"primaryKey" json:"username"`
- Password string `json:"password"`
- AccessToken string `json:"access_token,omitempty"`
- RefreshToken string `json:"refresh_token,omitempty"`
- ExpiresAt string `json:"expires_at,omitempty"`
-}
-
-// GetUserData retrieves the user data from the database.
-func GetUserData() (*User, error) {
- if Db == nil {
- return nil, fmt.Errorf("database connection is not initialized")
- }
-
- var user User
- if err := Db.First(&user).Error; err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return nil, nil // User not found
- }
- log.Error().Err(err).Msg("Failed to retrieve user data")
- return nil, err
- }
-
- if &user == nil {
- return nil, fmt.Errorf("no user data found. Please run 'gogg init' to enter your username and password")
- }
-
- return &user, nil
-}
-
-// UpsertUserData inserts or updates the user data in the database.
-func UpsertUserData(user *User) error {
- if Db == nil {
- return fmt.Errorf("database connection is not initialized")
- }
-
- if err := Db.Clauses(
- clause.OnConflict{
- UpdateAll: true, // Updates all fields if there's a conflict on the primary key (Username).
- },
- ).Create(user).Error; err != nil {
- log.Error().Err(err).Msgf("Failed to upsert user %s", user.Username)
- return err
- }
-
- log.Info().Msgf("User upserted successfully: %s", user.Username)
- return nil
-}
diff --git a/db/user_test.go b/db/user_test.go
deleted file mode 100644
index e4a71e9..0000000
--- a/db/user_test.go
+++ /dev/null
@@ -1,91 +0,0 @@
-package db_test
-
-import (
- "github.com/habedi/gogg/db"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- "gorm.io/driver/sqlite"
- "gorm.io/gorm"
- "testing"
-)
-
-func setupTestDBForUser(t *testing.T) *gorm.DB {
- dBOject, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- require.NoError(t, err)
- require.NoError(t, dBOject.AutoMigrate(&db.User{}))
- return dBOject
-}
-
-func TestGetUserData_ReturnsUser(t *testing.T) {
- testDB := setupTestDBForUser(t)
- db.Db = testDB
-
- user := &db.User{Username: "testuser", Password: "password"}
- err := db.UpsertUserData(user)
- require.NoError(t, err)
-
- retrievedUser, err := db.GetUserData()
- require.NoError(t, err)
- assert.NotNil(t, retrievedUser)
- assert.Equal(t, "testuser", retrievedUser.Username)
- assert.Equal(t, "password", retrievedUser.Password)
-}
-
-func TestGetUserData_ReturnsNilForNoUser(t *testing.T) {
- testDB := setupTestDBForUser(t)
- db.Db = testDB
-
- retrievedUser, err := db.GetUserData()
- require.NoError(t, err)
- assert.Nil(t, retrievedUser)
-}
-
-func TestGetUserData_ReturnsErrorForUninitializedDB(t *testing.T) {
- db.Db = nil
-
- retrievedUser, err := db.GetUserData()
- assert.Error(t, err)
- assert.Nil(t, retrievedUser)
-}
-
-func TestUpsertUserData_InsertsNewUser(t *testing.T) {
- testDB := setupTestDBForUser(t)
- db.Db = testDB
-
- user := &db.User{Username: "testuser", Password: "password"}
- err := db.UpsertUserData(user)
- require.NoError(t, err)
-
- var retrievedUser db.User
- err = testDB.First(&retrievedUser, "1 = 1").Error
- require.NoError(t, err)
- assert.Equal(t, "testuser", retrievedUser.Username)
- assert.Equal(t, "password", retrievedUser.Password)
-}
-
-func TestUpsertUserData_UpdatesExistingUser(t *testing.T) {
- testDB := setupTestDBForUser(t)
- db.Db = testDB
-
- user := &db.User{Username: "testuser", Password: "password"}
- err := db.UpsertUserData(user)
- require.NoError(t, err)
-
- updatedUser := &db.User{Username: "testuser", Password: "newpassword"}
- err = db.UpsertUserData(updatedUser)
- require.NoError(t, err)
-
- var retrievedUser db.User
- err = testDB.First(&retrievedUser, "1 = 1").Error
- require.NoError(t, err)
- assert.Equal(t, "testuser", retrievedUser.Username)
- assert.Equal(t, "newpassword", retrievedUser.Password)
-}
-
-func TestUpsertUserData_ReturnsErrorForUninitializedDB(t *testing.T) {
- db.Db = nil
-
- user := &db.User{Username: "testuser", Password: "password"}
- err := db.UpsertUserData(user)
- assert.Error(t, err)
-}
diff --git a/docs/README.md b/docs/README.md
index 33512ed..2540e2a 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,9 +1,7 @@
-## Dependency Notice
-
+> [!IMPORTANT]
> The current Gogg release needs [Google Chrome](https://www.google.com/chrome/) or
[Chromium](https://www.chromium.org/) as a dependency for the first-time authentication (logging into the GOG website
-> using
-> username and password).
+> using username and password).
> So, make sure you have one of them installed on your machine.
## Installation
@@ -14,27 +12,14 @@ You might want to add the binary to your system's PATH to use it from anywhere o
## Usage
-### First Time Setup
-
-To use Gogg, you must enter your GOG credentials using the `init` command.
-This command will save your credentials in Gogg's internal database.
-
-```sh
-gogg init
-```
-
-### Authentication
+### Login to GOG
-Use the `auth` command to authenticate with GOG using your saved credentials:
+Use the `login` command to login to your GOG account the first time you use Gogg.
```sh
-gogg auth
+gogg login
```
-The first time you run this command, Gogg will open a browser window to authenticate with GOG.
-After you log in, Gogg will save the authentication token in its internal database.
-After that, you can use the `auth` command to refresh the token if it is expired.
-
### Game Catalogue
Gogg stores information about the games you own on GOG in a local database called the (game) catalogue.
@@ -65,14 +50,14 @@ To search for games in the catalogue, you can use the `catalogue search` command
The search can be done either by the game ID or by a search term.
```sh
-# Search by the game ID
-gogg catalogue search --id=
+# Search by search a term (default)
+# The search term is case-insensitive and can be a partial match of the game title
+gogg catalogue search
```
```sh
-# Search by search a term
-# The search term is case-insensitive and can be a partial match of the game title
-gogg catalogue search --term=
+# Search by the game ID (use the --id flag)
+gogg catalogue search --id=true
```
#### Game Details
@@ -82,7 +67,7 @@ The command requires the game ID as an argument.
```sh
# Displays the detailed information about a game from the catalogue
-gogg catalogue info --id=
+gogg catalogue info
```
#### Exporting the Catalogue
@@ -94,7 +79,7 @@ If the format is CSV, the file will include the game ID, title of every game in
```sh
# Export the catalogue as CSV to a file in the specified directory
-gogg catalogue export --format=csv --dir=
+gogg catalogue export --format=csv
```
If the format is JSON, the file will include the full information about every game in the catalogue.
@@ -102,7 +87,7 @@ The full information is the data that GOG provides about the game.
```sh
# Export the catalogue as JSON to a file in the specified directory
-gogg catalogue export --format=json --dir=
+gogg catalogue export --format=json
```
### Downloading Game Files
@@ -111,7 +96,7 @@ To download game files, use the `download` command and provide it with the game
where you want to save the files.
```sh
-gogg download --id= --dir=
+gogg download
```
The `download` command supports the following additional options:
@@ -122,13 +107,15 @@ The `download` command supports the following additional options:
- `--extras`: Include extra files in the download like soundtracks, wallpapers, etc. (default is true)
- `--resume`: Resume interrupted downloads (default is true)
- `--threads`: Number of worker threads to use for downloading (default is 5)
+- `--flatten`: Flatten the directory structure of the downloaded files (default is true)
-For example, to download all files (English language) of a game with the ID `` to the directory ``
-with the specified
-options:
+For example, to download all files (English language) of a game with the ID `` to the directory
+``
+with the specified options:
```sh
-gogg download --id= --dir= --platform=all --lang=en --dlcs=true --extras=true --resume=true --threads=5
+gogg download --platform=all --lang=en --dlcs=true --extras=true \
+--resume=true --threads=5 --flatten=true
```
## Enabling Debug Mode
@@ -140,9 +127,3 @@ In debug mode, Gogg will be much more verbose and print a lot of information to
DEBUG_GOGG=true gogg
```
-## Internal Database
-
-Gogg's internal database that stores user's GOG credentials and the game catalogue is located in the user's home under
-the `.gogg` directory by default. The database file is named `games.db` and you should be able to transfer it to another
-machine to use Gogg without the need to re-enter your GOG credentials given you're using the same version of Gogg.
-
diff --git a/docs/examples/download_all_games.ps1 b/docs/examples/download_all_games.ps1
index 3f207a9..11255f6 100644
--- a/docs/examples/download_all_games.ps1
+++ b/docs/examples/download_all_games.ps1
@@ -20,6 +20,8 @@ $INCLUDE_DLC = 1 # Include DLCs
$INCLUDE_EXTRA_CONTENT = 1 # Include extra content
$RESUME_DOWNLOAD = 1 # Resume download
$NUM_THREADS = 4 # Number of worker threads for downloading
+$FLATTEN = 1 # Flatten directory structure
+$OUTPUT_DIR = "./games" # Output directory
# Function to clean up the CSV file
function Cleanup
@@ -35,8 +37,8 @@ function Cleanup
}
# Update game catalogue and export it to a CSV file
-& $GOGG catalogue refresh --threads=10
-& $GOGG catalogue export --format=csv --dir=./
+& $GOGG catalogue refresh
+& $GOGG catalogue export ./ --format=csv
# Find the newest catalogue file
$latest_csv = Get-ChildItem -Path . -Filter "gogg_catalogue_*.csv" | Sort-Object LastWriteTime -Descending | Select-Object -First 1
@@ -57,8 +59,9 @@ Get-Content $latest_csv.FullName | Select-Object -Skip 1 | ForEach-Object {
$game_title = $fields[1]
Write-Host "${YELLOW}Game ID: $game_id, Title: $game_title${NC}"
$env:DEBUG_GOGG = $DEBUG_MODE
- & $GOGG download --id=$game_id --dir="./games" --platform=$PLATFORM --lang=$LANG `
- --dlcs=$INCLUDE_DLC --extras=$INCLUDE_EXTRA_CONTENT --resume=$RESUME_DOWNLOAD --threads=$NUM_THREADS
+ & $GOGG download $game_id $OUTPUT_DIR --platform=$PLATFORM --lang=$LANG `
+ --dlcs=$INCLUDE_DLC --extras=$INCLUDE_EXTRA_CONTENT --resume=$RESUME_DOWNLOAD --threads=$NUM_THREADS `
+ --flatten=$FLATTEN
Start-Sleep -Seconds 1
}
diff --git a/docs/examples/download_all_games.sh b/docs/examples/download_all_games.sh
index afd67ae..af65f4d 100644
--- a/docs/examples/download_all_games.sh
+++ b/docs/examples/download_all_games.sh
@@ -19,6 +19,8 @@ INCLUDE_DLC=1 # Include DLCs
INCLUDE_EXTRA_CONTENT=1 # Include extra content
RESUME_DOWNLOAD=1 # Resume download
NUM_THREADS=4 # Number of worker threads for downloading
+FLATTEN=1 # Flatten the directory structure
+OUTPUT_DIR=./games # Output directory
# Function to clean up the CSV file
cleanup() {
@@ -34,8 +36,8 @@ cleanup() {
trap cleanup SIGINT
# Update game catalogue and export it to a CSV file
-$GOGG catalogue refresh --threads=10
-$GOGG catalogue export --format=csv --dir=./
+$GOGG catalogue refresh
+$GOGG catalogue export ./ --format=csv
# Find the newest catalogue file
latest_csv=$(ls -t gogg_catalogue_*.csv 2>/dev/null | head -n 1)
@@ -51,8 +53,9 @@ echo -e "${GREEN}Using catalogue file: $latest_csv${NC}"
# Download each game listed in catalogue file, skipping the first line
tail -n +2 "$latest_csv" | while IFS=, read -r game_id game_title; do
echo -e "${YELLOW}Game ID: $game_id, Title: $game_title${NC}"
- DEBUG_GOGG=$DEBUG_MODE $GOGG download --id=$game_id --dir=./games --platform=$PLATFORM --lang=$LANG \
- --dlcs=$INCLUDE_DLC --extras=$INCLUDE_EXTRA_CONTENT --resume=$RESUME_DOWNLOAD --threads=$NUM_THREADS
+ DEBUG_GOGG=$DEBUG_MODE $GOGG download "$game_id" $OUTPUT_DIR --platform=$PLATFORM --lang=$LANG \
+ --dlcs=$INCLUDE_DLC --extras=$INCLUDE_EXTRA_CONTENT --resume=$RESUME_DOWNLOAD --threads=$NUM_THREADS \
+ --flatten=$FLATTEN
sleep 1
#break # Comment out this line to download all games
done
diff --git a/docs/examples/simple_example.sh b/docs/examples/simple_example.sh
index cb824fb..447d516 100644
--- a/docs/examples/simple_example.sh
+++ b/docs/examples/simple_example.sh
@@ -1,31 +1,37 @@
#!/bin/bash
-## Simple examples of using Gogg commands
+echo "Sample script to demonstrate Gogg's basic functionalities"
+sleep 1
-GOGG=$(command -v bin/gogg || command -v gogg)
+# Find the Gogg executable
+GOGG=$(command -v bin/gogg || command -v gogg || command -v ./gogg)
-# Show Gogg's commands
+echo "Show Gogg's top-level commands"
$GOGG --help
+sleep 1
-# Show the Gogg version
+echo "Show the version"
$GOGG version
+sleep 1
-# Initialize Gogg's internal database and ask for user's credentials
-$GOGG init
+#echo "Login to GOG.com"
+#$GOGG login
+#sleep 1
-# Login to GOG.com (headless mode; no browser window is opened)
-$GOGG auth --show=false
+echo "Update game catalogue with the data from GOG.com"
+$GOGG catalogue refresh
+sleep 1
-# Update game catalogue with the data from GOG.com
-$GOGG catalogue refresh --threads=10
+echo "Search for games with specific terms in their titles"
+$GOGG catalogue search "Witcher"
+$GOGG catalogue search "mess"
-# Search for games with specific terms in their titles
-$GOGG catalogue search --term "Witcher"
-$GOGG catalogue search --term "mess"
+echo "Download a specific game (\"The Messenger\") with the given options"
+$GOGG download 1433116924 ./games --platform=all --lang=en --threads=4 \
+ --dlcs=true --extras=false --resume=true --flatten=true
-# Download a specific game ("The Messenger") with the given options
-$GOGG download --id=1433116924 --dir=./games --platform=all --lang=en --threads=3 \
- --dlcs=false --extras=false --resume=true
-
-# Show the downloaded game files
+echo "Show the downloaded game files"
tree ./games
+
+echo "Display hash values of the downloaded game files"
+$GOGG file hash ./games --algo=md5
diff --git a/main.go b/main.go
index 696f820..ecdd522 100644
--- a/main.go
+++ b/main.go
@@ -9,6 +9,9 @@ import (
"github.com/rs/zerolog/log"
)
+// main is the entry point of the application.
+// It sets up logging based on the DEBUG_GOGG environment variable,
+// starts a goroutine to listen for interrupt signals, and executes the main command.
func main() {
// If the DEBUG_GOGG environment variable is set, enable debug logging to stdout, otherwise disable logging
@@ -27,7 +30,8 @@ func main() {
cmd.Execute()
}
-// listenForInterrupt listens for an interrupt signal and exits the program when it is received
+// listenForInterrupt listens for an interrupt signal and exits the program when it is received.
+// It takes a channel of os.Signal as a parameter.
func listenForInterrupt(stopScan chan os.Signal) {
<-stopScan
log.Fatal().Msg("Interrupt signal received. Exiting...")