diff --git a/README.md b/README.md index 6502b90..9f63806 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@
- Go Report Card + Go Report Card Go Reference @@ -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...")