| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203 |
- // Command cheapo lists and watches free game offers (Epic Games Store, Steam).
- //
- // 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
- import (
- "context"
- "encoding/json"
- "fmt"
- "os"
- "os/signal"
- "syscall"
- "time"
- "github.com/spf13/cobra"
- "gogs.netdome.biz/paul/cheapo/internal/game"
- "gogs.netdome.biz/paul/cheapo/internal/ops"
- "gogs.netdome.biz/paul/cheapo/internal/providers"
- "gogs.netdome.biz/paul/cheapo/internal/watch"
- )
- var (
- flagCountry string
- flagLocale string
- flagJSON bool
- flagStore string
- )
- func main() {
- root := &cobra.Command{
- Use: "cheapo",
- Short: "cheapo — free games lister (Go edition)",
- Long: `cheapo lists and watches free game offers (Epic Games Store, Steam).
- 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,
- }
- root.PersistentFlags().StringVar(&flagCountry, "country", "DE", "ISO country code for store region; determines currency/availability (epic only)")
- root.PersistentFlags().StringVar(&flagLocale, "locale", "de", "display locale for Epic (formatting only, e.g. de -> '22,39 €', en-US -> '€22.39'; currency comes from --country)")
- root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON")
- root.PersistentFlags().StringVar(&flagStore, "store", "all", "which store(s): epic, steam, gog, all")
- root.AddCommand(listCmd(), watchCmd(), mcpCmd(), chattoSendCmd(), chattoRoomsCmd())
- if err := root.Execute(); err != nil {
- os.Exit(1)
- }
- }
- // newProviders returns the providers selected by --store.
- func newProviders() ([]game.Provider, error) {
- return providers.New(providers.Options{
- Store: flagStore,
- Country: flagCountry,
- Locale: flagLocale,
- })
- }
- // fetchList runs ops.List over the providers selected by --store. A failing
- // provider is reported on stderr but does not abort the others.
- func fetchList(ctx context.Context, mode ops.Mode) ([]game.Game, error) {
- provs, err := newProviders()
- if err != nil {
- return nil, err
- }
- games, warnings, err := ops.List(ctx, provs, mode)
- if err != nil {
- return nil, err
- }
- for _, w := range warnings {
- fmt.Fprintf(os.Stderr, "⚠ %s\n", w)
- }
- return games, nil
- }
- // printGames renders games in CLI text form.
- func printGames(cmd *cobra.Command, games []game.Game) error {
- if flagJSON {
- enc := json.NewEncoder(os.Stdout)
- enc.SetIndent("", " ")
- return enc.Encode(games)
- }
- if len(games) == 0 {
- fmt.Fprintln(cmd.OutOrStdout(), "No matches.")
- return nil
- }
- out := cmd.OutOrStdout()
- for _, g := range games {
- 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 != "" {
- fmt.Fprintf(out, " By: %s\n", g.Seller)
- }
- if g.DiscountPct > 0 {
- if g.FinalPriceFmt != "" {
- fmt.Fprintf(out, " Price: %s → %s (−%d %%)\n", g.PriceFmt, g.FinalPriceFmt, g.DiscountPct)
- } else {
- fmt.Fprintf(out, " Price: %s (−%d %%)\n", g.PriceFmt, g.DiscountPct)
- }
- } else if g.PriceFmt != "" && g.Store != "steam" && g.PriceFmt != "0" {
- fmt.Fprintf(out, " Instead of: %s\n", g.PriceFmt)
- }
- if g.Kind == "upcoming" && !g.StartDate.IsZero() {
- fmt.Fprintf(out, " Free from: %s (until %s)\n",
- g.StartDate.Local().Format("02.01.2006 15:04"),
- g.EndDate.Local().Format("02.01.2006 15:04"))
- } else if !g.EndDate.IsZero() {
- fmt.Fprintf(out, " Free until: %s\n", g.EndDate.UTC().Format(time.RFC3339))
- }
- fmt.Fprintf(out, " %s\n", g.URL)
- fmt.Fprintln(out)
- }
- return nil
- }
- func listCmd() *cobra.Command {
- var (
- upcoming bool
- deals bool
- )
- cmd := &cobra.Command{
- Use: "list",
- Aliases: []string{"ls"},
- Short: "List games currently free (optionally upcoming or deals)",
- RunE: func(cmd *cobra.Command, args []string) error {
- mode := ops.Free
- if upcoming {
- mode = ops.Upcoming
- } else if deals {
- mode = ops.Deals
- }
- games, err := fetchList(cmd.Context(), mode)
- if err != nil {
- return err
- }
- return printGames(cmd, games)
- },
- }
- cmd.Flags().BoolVar(&upcoming, "upcoming", false, "Show announced upcoming free games (Epic only)")
- cmd.Flags().BoolVar(&deals, "deals", false, "Show discounted games (deals) instead of free games")
- return cmd
- }
- func watchCmd() *cobra.Command {
- var (
- interval time.Duration
- once bool
- )
- cmd := &cobra.Command{
- Use: "watch",
- Short: "Wait for new free games (polling)",
- 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).
- A store that is currently unreachable does not abort watching.
- Exit with Ctrl+C.`,
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
- defer stop()
- provs, err := newProviders()
- if err != nil {
- return err
- }
- if once {
- 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, all)
- }
- 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"))
- for _, g := range games {
- 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)
- }
- }))
- },
- }
- cmd.Flags().DurationVar(&interval, "interval", 15*time.Minute, "Polling interval")
- cmd.Flags().BoolVar(&once, "once", false, "Check once and exit")
- return cmd
- }
|