|
@@ -1,10 +1,12 @@
|
|
|
-// Command cheapo lists and watches free game offers (currently: Epic Games Store).
|
|
|
|
|
|
|
+// Command cheapo lists and watches free game offers (Epic Games Store, Steam).
|
|
|
//
|
|
//
|
|
|
-// Data is read from the public storefront backend, no browser and no login
|
|
|
|
|
-// needed. Reference implementation: vogler/free-games-claimer.
|
|
|
|
|
|
|
+// Data is read from public storefront backends, no browser and no login
|
|
|
|
|
+// needed. Reference implementations: vogler/free-games-claimer,
|
|
|
|
|
+// jaixmario/free-games-notifier.
|
|
|
package main
|
|
package main
|
|
|
|
|
|
|
|
import (
|
|
import (
|
|
|
|
|
+ "context"
|
|
|
"encoding/json"
|
|
"encoding/json"
|
|
|
"fmt"
|
|
"fmt"
|
|
|
"os"
|
|
"os"
|
|
@@ -15,6 +17,8 @@ import (
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/cobra"
|
|
|
|
|
|
|
|
"gogs.netdome.biz/paul/cheapo/internal/epic"
|
|
"gogs.netdome.biz/paul/cheapo/internal/epic"
|
|
|
|
|
+ "gogs.netdome.biz/paul/cheapo/internal/game"
|
|
|
|
|
+ "gogs.netdome.biz/paul/cheapo/internal/steam"
|
|
|
"gogs.netdome.biz/paul/cheapo/internal/watch"
|
|
"gogs.netdome.biz/paul/cheapo/internal/watch"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
@@ -22,22 +26,25 @@ var (
|
|
|
flagCountry string
|
|
flagCountry string
|
|
|
flagLocale string
|
|
flagLocale string
|
|
|
flagJSON bool
|
|
flagJSON bool
|
|
|
|
|
+ flagStore string
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
func main() {
|
|
func main() {
|
|
|
root := &cobra.Command{
|
|
root := &cobra.Command{
|
|
|
Use: "cheapo",
|
|
Use: "cheapo",
|
|
|
Short: "cheapo — free games lister (Go edition)",
|
|
Short: "cheapo — free games lister (Go edition)",
|
|
|
- Long: `cheapo lists and watches free game offers (currently: Epic Games Store).
|
|
|
|
|
|
|
+ Long: `cheapo lists and watches free game offers (Epic Games Store, Steam).
|
|
|
|
|
|
|
|
-Data is read from the public storefront backend, no browser and no login needed.
|
|
|
|
|
-Reference: https://github.com/vogler/free-games-claimer`,
|
|
|
|
|
|
|
+Data is read from public storefront backends, no browser and no login needed.
|
|
|
|
|
+References: https://github.com/vogler/free-games-claimer
|
|
|
|
|
+ https://github.com/jaixmario/free-games-notifier`,
|
|
|
SilenceUsage: true,
|
|
SilenceUsage: true,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- root.PersistentFlags().StringVar(&flagCountry, "country", "DE", "ISO country code for store region")
|
|
|
|
|
|
|
+ root.PersistentFlags().StringVar(&flagCountry, "country", "DE", "ISO country code for store region (epic)")
|
|
|
root.PersistentFlags().StringVar(&flagLocale, "locale", "de", "store locale (e.g. de, en-US)")
|
|
root.PersistentFlags().StringVar(&flagLocale, "locale", "de", "store locale (e.g. de, en-US)")
|
|
|
root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON")
|
|
root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON")
|
|
|
|
|
+ root.PersistentFlags().StringVar(&flagStore, "store", "all", "which store(s): epic, steam, all")
|
|
|
|
|
|
|
|
root.AddCommand(listCmd(), watchCmd())
|
|
root.AddCommand(listCmd(), watchCmd())
|
|
|
|
|
|
|
@@ -46,14 +53,51 @@ Reference: https://github.com/vogler/free-games-claimer`,
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-func newClient() *epic.Client {
|
|
|
|
|
|
|
+// newProviders returns the providers selected by --store.
|
|
|
|
|
+func newProviders() ([]game.Provider, error) {
|
|
|
|
|
+ var provs []game.Provider
|
|
|
|
|
+ switch flagStore {
|
|
|
|
|
+ case "epic":
|
|
|
|
|
+ provs = []game.Provider{newEpic()}
|
|
|
|
|
+ case "steam":
|
|
|
|
|
+ provs = []game.Provider{steam.NewClient()}
|
|
|
|
|
+ case "all":
|
|
|
|
|
+ provs = []game.Provider{newEpic(), steam.NewClient()}
|
|
|
|
|
+ default:
|
|
|
|
|
+ return nil, fmt.Errorf("unknown store %q (epic, steam, all)", flagStore)
|
|
|
|
|
+ }
|
|
|
|
|
+ return provs, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func newEpic() *epic.Client {
|
|
|
c := epic.NewClient()
|
|
c := epic.NewClient()
|
|
|
c.Country = flagCountry
|
|
c.Country = flagCountry
|
|
|
c.Locale = flagLocale
|
|
c.Locale = flagLocale
|
|
|
return c
|
|
return c
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-func printGames(cmd *cobra.Command, games []epic.Game) error {
|
|
|
|
|
|
|
+// fetchAll queries all selected providers; a failing provider is reported on
|
|
|
|
|
+// stderr but does not abort the others.
|
|
|
|
|
+func fetchAll(ctx context.Context) ([]game.Game, error) {
|
|
|
|
|
+ provs, err := newProviders()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ var all []game.Game
|
|
|
|
|
+ var hadErr error
|
|
|
|
|
+ for _, p := range provs {
|
|
|
|
|
+ games, err := p.GetFreeGames(ctx)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ fmt.Fprintf(os.Stderr, "⚠ %s: %v\n", p.Name(), err)
|
|
|
|
|
+ hadErr = err
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ all = append(all, games...)
|
|
|
|
|
+ }
|
|
|
|
|
+ return all, hadErr
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func printGames(cmd *cobra.Command, games []game.Game) error {
|
|
|
if flagJSON {
|
|
if flagJSON {
|
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc := json.NewEncoder(os.Stdout)
|
|
|
enc.SetIndent("", " ")
|
|
enc.SetIndent("", " ")
|
|
@@ -65,14 +109,19 @@ func printGames(cmd *cobra.Command, games []epic.Game) error {
|
|
|
}
|
|
}
|
|
|
out := cmd.OutOrStdout()
|
|
out := cmd.OutOrStdout()
|
|
|
for _, g := range games {
|
|
for _, g := range games {
|
|
|
- fmt.Fprintf(out, "🎮 %s\n", g.Title)
|
|
|
|
|
|
|
+ fmt.Fprintf(out, "🎮 [%s] %s\n", g.Store, g.Title)
|
|
|
|
|
+ if g.Kind != "" && g.Kind != "free-now" {
|
|
|
|
|
+ fmt.Fprintf(out, " Kind: %s\n", g.Kind)
|
|
|
|
|
+ }
|
|
|
if g.Seller != "" {
|
|
if g.Seller != "" {
|
|
|
fmt.Fprintf(out, " By: %s\n", g.Seller)
|
|
fmt.Fprintf(out, " By: %s\n", g.Seller)
|
|
|
}
|
|
}
|
|
|
- if g.PriceFmt != "" {
|
|
|
|
|
|
|
+ if g.PriceFmt != "" && g.Store != "steam" {
|
|
|
fmt.Fprintf(out, " Instead of: %s\n", g.PriceFmt)
|
|
fmt.Fprintf(out, " Instead of: %s\n", g.PriceFmt)
|
|
|
}
|
|
}
|
|
|
- fmt.Fprintf(out, " Free until: %s\n", g.EndDate.Local().Format("02.01.2006 15:04 (MST)"))
|
|
|
|
|
|
|
+ if !g.EndDate.IsZero() {
|
|
|
|
|
+ fmt.Fprintf(out, " Free until: %s\n", g.EndDate.Local().Format("02.01.2006 15:04 (MST)"))
|
|
|
|
|
+ }
|
|
|
fmt.Fprintf(out, " %s\n", g.URL)
|
|
fmt.Fprintf(out, " %s\n", g.URL)
|
|
|
fmt.Fprintln(out)
|
|
fmt.Fprintln(out)
|
|
|
}
|
|
}
|
|
@@ -85,8 +134,8 @@ func listCmd() *cobra.Command {
|
|
|
Aliases: []string{"ls"},
|
|
Aliases: []string{"ls"},
|
|
|
Short: "List games currently free",
|
|
Short: "List games currently free",
|
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
|
- games, err := newClient().GetFreeGames(cmd.Context())
|
|
|
|
|
- if err != nil {
|
|
|
|
|
|
|
+ games, err := fetchAll(cmd.Context())
|
|
|
|
|
+ if err != nil && len(games) == 0 {
|
|
|
return err
|
|
return err
|
|
|
}
|
|
}
|
|
|
return printGames(cmd, games)
|
|
return printGames(cmd, games)
|
|
@@ -102,28 +151,41 @@ func watchCmd() *cobra.Command {
|
|
|
cmd := &cobra.Command{
|
|
cmd := &cobra.Command{
|
|
|
Use: "watch",
|
|
Use: "watch",
|
|
|
Short: "Wait for new free games (polling)",
|
|
Short: "Wait for new free games (polling)",
|
|
|
- Long: `Polls the store and reports every game that becomes newly free.
|
|
|
|
|
|
|
+ Long: `Polls all selected stores and reports every game that becomes newly free.
|
|
|
The first pass records the current state as the baseline (no reports).
|
|
The first pass records the current state as the baseline (no reports).
|
|
|
|
|
+A store that is currently unreachable does not abort watching.
|
|
|
Exit with Ctrl+C.`,
|
|
Exit with Ctrl+C.`,
|
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
|
ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
|
|
ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
|
|
|
defer stop()
|
|
defer stop()
|
|
|
|
|
|
|
|
- c := newClient()
|
|
|
|
|
|
|
+ provs, err := newProviders()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
if once {
|
|
if once {
|
|
|
- games, err := c.GetFreeGames(ctx)
|
|
|
|
|
- if err != nil {
|
|
|
|
|
- return err
|
|
|
|
|
|
|
+ var all []game.Game
|
|
|
|
|
+ for _, p := range provs {
|
|
|
|
|
+ games, err := p.GetFreeGames(ctx)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ fmt.Fprintf(os.Stderr, "⚠ %s: %v\n", p.Name(), err)
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ all = append(all, games...)
|
|
|
}
|
|
}
|
|
|
- return printGames(cmd, games)
|
|
|
|
|
|
|
+ return printGames(cmd, all)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- w := watch.NewWatcher(c, interval)
|
|
|
|
|
- return w.Run(ctx, false, watch.NotifierFunc(func(games []epic.Game) {
|
|
|
|
|
|
|
+ w := watch.NewWatcher(interval, provs...)
|
|
|
|
|
+ return w.Run(ctx, false, watch.NotifierFunc(func(games []game.Game) {
|
|
|
fmt.Fprintf(os.Stderr, "\n🔔 New free games (%s):\n", time.Now().Format("15:04:05"))
|
|
fmt.Fprintf(os.Stderr, "\n🔔 New free games (%s):\n", time.Now().Format("15:04:05"))
|
|
|
for _, g := range games {
|
|
for _, g := range games {
|
|
|
- fmt.Fprintf(os.Stderr, " 🎮 %s — until %s — %s\n",
|
|
|
|
|
- g.Title, g.EndDate.Local().Format("02.01."), g.URL)
|
|
|
|
|
|
|
+ end := "—"
|
|
|
|
|
+ if !g.EndDate.IsZero() {
|
|
|
|
|
+ end = g.EndDate.Local().Format("02.01.")
|
|
|
|
|
+ }
|
|
|
|
|
+ fmt.Fprintf(os.Stderr, " 🎮 [%s] %s — until %s — %s\n", g.Store, g.Title, end, g.URL)
|
|
|
}
|
|
}
|
|
|
}))
|
|
}))
|
|
|
},
|
|
},
|