Compare commits

...

8 Commits

Author SHA1 Message Date
Felipe Martin Garcia af72c45833 feat: cache support (#19)
- Added file and memory cache handlers
- Steam provider now uses a cache to store the app list for 24h

Closes #12
2022-01-30 19:26:29 +01:00
Felipe M 3d56f84762 feat: improved logging with logrus
Propagated a logrus.Logger/Entry along for the processor, registry and
providers to use for events that may not require to stop processing but
are useful to know otherwise.

Changed some functions to return errors instead of failing with a logger
trying to centralice the logging or returning of the main failing points
in the main components of the application.

Closes #13
2022-01-30 19:26:29 +01:00
Felipe M 76afe81086 fix: nil-pointer error 2022-01-30 19:26:29 +01:00
Felipe M d0c2d4ff07 feat: concurrent processing
closes #14
2022-01-30 19:26:29 +01:00
Felipe M 01e3906a9a refactor: working on new version
- Refactored all providers, all following the same interface
- Added the registry component where all providers get initializated
- Added the processor component in charge of processing
  screenshots/covers
- Split configuration into Options and ProviderOptions
- Refactored the workaround for the covers, now the game provide a
  CoverURL and the processor decides to download it or not
- Made the providers folder hierarchy more clear, and moved helper
  functions to other files for sanity
- Simplified CLI
2022-01-30 19:26:29 +01:00
Felipe M 0d21736081 feat: updated dependencies 2022-01-30 19:26:29 +01:00
Felipe M dbbddc1242 chore: moved test file 2022-01-30 19:26:29 +01:00
Felipe M 0f3382ebf9 chore: added logger to switch provider 2022-01-30 19:26:29 +01:00
28 changed files with 1109 additions and 639 deletions

2
.gitignore vendored
View File

@ -2,3 +2,5 @@ Output
Album
build
dist/
.DS_Store
Output*

3
go.mod
View File

@ -3,6 +3,7 @@ module github.com/fmartingr/games-screenshot-manager
go 1.15
require (
github.com/gosimple/slug v1.9.0
github.com/gosimple/slug v1.12.0
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
github.com/sirupsen/logrus v1.8.1
)

18
go.sum
View File

@ -1,6 +1,16 @@
github.com/gosimple/slug v1.9.0 h1:r5vDcYrFz9BmfIAMC829un9hq7hKM4cHUrsv36LbEqs=
github.com/gosimple/slug v1.9.0/go.mod h1:AMZ+sOVe65uByN3kgEyf9WEBKBCSS+dJjMX9x4vDJbg=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gosimple/slug v1.12.0 h1:xzuhj7G7cGtd34NXnW/yF0l+AGNfWqwgh/IXgFy7dnc=
github.com/gosimple/slug v1.12.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@ -1,25 +1,22 @@
package cli
import (
"bytes"
"context"
"flag"
"log"
"os"
"path/filepath"
"strings"
"github.com/fmartingr/games-screenshot-manager/pkg/helpers"
"github.com/fmartingr/games-screenshot-manager/pkg/providers"
"github.com/fmartingr/games-screenshot-manager/internal/models"
"github.com/fmartingr/games-screenshot-manager/pkg/cache"
"github.com/fmartingr/games-screenshot-manager/pkg/processor"
"github.com/fmartingr/games-screenshot-manager/pkg/providers/minecraft"
"github.com/fmartingr/games-screenshot-manager/pkg/providers/nintendo_switch"
"github.com/fmartingr/games-screenshot-manager/pkg/providers/playstation4"
"github.com/fmartingr/games-screenshot-manager/pkg/providers/retroarch"
"github.com/fmartingr/games-screenshot-manager/pkg/providers/steam"
"github.com/gosimple/slug"
"github.com/fmartingr/games-screenshot-manager/pkg/registry"
"github.com/sirupsen/logrus"
)
var allowedProviders = [...]string{"steam", "minecraft", "nintendo-switch", "playstation-4", "retroarch"}
const defaultOutputPath string = "./Output"
const defaultInputPath string = ""
@ -28,104 +25,65 @@ const defaultDryRun bool = false
const defaultDownloadCovers bool = false
func Start() {
cliOptions := providers.ProviderOptions{
OutputPath: flag.String("output-path", defaultOutputPath, "The destination path of the screenshots"),
InputPath: flag.String("input-path", defaultInputPath, "Input path for the provider that requires it"),
DownloadCovers: flag.Bool("download-covers", defaultDownloadCovers, "use to enable the download of covers (if the provider supports it)"),
DryRun: flag.Bool("dry-run", defaultDryRun, "Use to disable write actions on filesystem"),
logger := logrus.New()
flagSet := flag.NewFlagSet("gsm", flag.ExitOnError)
cache := cache.NewFileCache(logger)
registry := registry.NewProviderRegistry(logger, cache)
registry.Register(minecraft.Name, minecraft.NewMinecraftProvider)
registry.Register(nintendo_switch.Name, nintendo_switch.NewNintendoSwitchProvider)
registry.Register(playstation4.Name, playstation4.NewPlaystation4Provider)
registry.Register(steam.Name, steam.NewSteamProvider)
registry.Register(retroarch.Name, retroarch.NewRetroArchProvider)
options := models.Options{
ProcessBufferSize: 32,
}
var provider = flag.String("provider", defaultProvider, "steam")
flag.Parse()
flagSet.StringVar(&options.OutputPath, "output-path", defaultOutputPath, "The destination path of the screenshots")
flagSet.BoolVar(&options.DownloadCovers, "download-covers", defaultDownloadCovers, "use to enable the download of covers (if the provider supports it)")
flagSet.BoolVar(&options.DryRun, "dry-run", defaultDryRun, "Use to disable write actions on filesystem")
flagSet.IntVar(&options.WorkersNum, "workers-num", 2, "Number of workers to use to process games")
if helpers.SliceContainsString(allowedProviders[:], *provider, nil) {
games := getGamesFromProvider(*provider, cliOptions)
processGames(games, cliOptions)
} else {
log.Printf("Provider %s not found!", *provider)
var providerName = flagSet.String("provider", defaultProvider, "steam")
providerOptions := models.ProviderOptions{}
flagSet.StringVar(&providerOptions.InputPath, "input-path", defaultInputPath, "Input path for the provider that requires it")
loglevelFlag := flagSet.String("log-level", logrus.InfoLevel.String(), "Log level")
if err := flagSet.Parse(os.Args[1:]); err != nil {
logger.Errorf("error parsing args: %s", err)
}
loglevel, err := logrus.ParseLevel(*loglevelFlag)
if err != nil {
logger.Warnf("Invalid loglevel %s, using %s instead.", *loglevelFlag, logrus.InfoLevel.String())
loglevel = logrus.InfoLevel
}
logger.SetLevel(loglevel)
provider, err := registry.Get(*providerName)
if err != nil {
logger.Errorf("Provider %s not found!", *providerName)
return
}
games, err := provider.FindGames(providerOptions)
if err != nil {
logger.Errorf("Error obtaining game list: %s", err)
return
}
if len(games) > 0 {
ctx, cancel := context.WithCancel(context.Background())
processor := processor.NewProcessor(logger, options)
processor.Start(ctx)
for _, g := range games {
processor.Process(g)
}
processor.Wait()
cancel()
}
}
func getGamesFromProvider(provider string, cliOptions providers.ProviderOptions) []providers.Game {
var games []providers.Game
switch provider {
case "steam":
games = append(games, steam.GetGames(cliOptions)...)
case "minecraft":
games = append(games, minecraft.GetGames(cliOptions)...)
case "nintendo-switch":
games = append(games, nintendo_switch.GetGames(cliOptions)...)
case "playstation-4":
games = append(games, playstation4.GetGames(cliOptions)...)
case "retroarch":
games = append(games, retroarch.GetGames(cliOptions)...)
}
return games
}
// TODO: Reduce into smaller functions
func processGames(games []providers.Game, cliOptions providers.ProviderOptions) {
for _, game := range games {
destinationPath := filepath.Join(helpers.ExpandUser(*cliOptions.OutputPath), game.Platform)
if len(game.Name) > 0 {
destinationPath = filepath.Join(destinationPath, game.Name)
} else {
log.Printf("[IMPORTANT] Game ID %s has no name!", game.ID)
destinationPath = filepath.Join(destinationPath, game.ID)
}
// Do not continue if there's no screenshots
if len(game.Screenshots) == 0 {
continue
}
// Check if folder exists
if _, err := os.Stat(destinationPath); os.IsNotExist(err) && !*cliOptions.DryRun {
mkdirErr := os.MkdirAll(destinationPath, 0711)
if mkdirErr != nil {
log.Printf("[ERROR] Couldn't create directory with name %s, falling back to %s", game.Name, slug.Make(game.Name))
destinationPath = filepath.Join(helpers.ExpandUser(*cliOptions.OutputPath), game.Platform, slug.Make(game.Name))
os.MkdirAll(destinationPath, 0711)
}
}
if *cliOptions.DownloadCovers && !*cliOptions.DryRun && game.Cover.Path != "" {
destinationCoverPath := filepath.Join(destinationPath, game.Cover.DestinationName)
if _, err := os.Stat(destinationCoverPath); os.IsNotExist(err) {
helpers.CopyFile(game.Cover.Path, destinationCoverPath)
}
}
log.Printf("=> Processing screenshots for %s %s", game.Name, game.Notes)
for _, screenshot := range game.Screenshots {
destinationPath := filepath.Join(destinationPath, screenshot.GetDestinationName())
if _, err := os.Stat(destinationPath); !os.IsNotExist(err) {
sourceMd5, err := helpers.Md5File(screenshot.Path)
if err != nil {
log.Fatal(err)
continue
}
destinationMd5, err := helpers.Md5File(destinationPath)
if err != nil {
log.Fatal(err)
continue
}
if !bytes.Equal(sourceMd5, destinationMd5) {
// Images are not equal, we should copy it anyway, but how?
log.Println("Found different screenshot with equal timestamp for game ", game.Name, screenshot.Path)
}
} else {
if *cliOptions.DryRun {
log.Println(filepath.Base(screenshot.Path), " -> ", strings.Replace(destinationPath, helpers.ExpandUser(*cliOptions.OutputPath), "", 1))
} else {
helpers.CopyFile(screenshot.Path, destinationPath)
}
}
}
}
}

15
internal/models/cache.go Normal file
View File

@ -0,0 +1,15 @@
package models
import (
"errors"
"time"
)
var ErrCacheKeyDontExist = errors.New("cache key don't exist")
type Cache interface {
Delete(key string) error
Get(key string) (string, error)
GetExpiry(key string, expiration time.Duration) (string, error)
Put(key, value string) error
}

View File

@ -1,4 +1,4 @@
package providers
package models
import (
"log"
@ -8,13 +8,6 @@ import (
const DatetimeFormat = "2006-01-02_15-04-05"
type ProviderOptions struct {
OutputPath *string
InputPath *string
DownloadCovers *bool
DryRun *bool
}
type Game struct {
ID string
Name string
@ -22,7 +15,16 @@ type Game struct {
Provider string
Screenshots []Screenshot
Notes string
Cover Screenshot
CoverURL string
}
func NewGame(id, name, platform, provider string) Game {
return Game{
ID: id,
Name: name,
Platform: platform,
Provider: provider,
}
}
type Screenshot struct {
@ -40,3 +42,16 @@ func (screenshot Screenshot) GetDestinationName() string {
}
return fileStat.ModTime().Format(DatetimeFormat) + filepath.Ext(screenshot.Path)
}
func NewScreenshot(path, destinationName string) Screenshot {
return Screenshot{
Path: path,
DestinationName: destinationName,
}
}
func NewScreenshotWithoutDestination(path string) Screenshot {
return Screenshot{
Path: path,
}
}

View File

@ -0,0 +1,9 @@
package models
type Options struct {
OutputPath string
DryRun bool
DownloadCovers bool
ProcessBufferSize int
WorkersNum int
}

View File

@ -0,0 +1,13 @@
package models
import "github.com/sirupsen/logrus"
type ProviderOptions struct {
InputPath string
}
type Provider interface {
FindGames(options ProviderOptions) ([]*Game, error)
}
type ProviderFactory func(logger *logrus.Logger, cache Cache) Provider

78
pkg/cache/file.go vendored Normal file
View File

@ -0,0 +1,78 @@
package cache
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/fmartingr/games-screenshot-manager/internal/models"
"github.com/sirupsen/logrus"
)
type FileCache struct {
logger *logrus.Entry
path string
}
func (c *FileCache) Get(key string) (result string, err error) {
path := filepath.Join(c.path, key)
contents, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) {
return result, models.ErrCacheKeyDontExist
}
return
}
return string(contents), nil
}
func (c *FileCache) GetExpiry(key string, expiration time.Duration) (result string, err error) {
path := filepath.Join(c.path, key)
info, err := os.Stat(path)
if err != nil {
return result, models.ErrCacheKeyDontExist
}
if info.ModTime().Add(expiration).Before(time.Now()) {
c.Delete(key)
return result, models.ErrCacheKeyDontExist
}
return c.Get(key)
}
func (c *FileCache) Put(key, value string) error {
path := filepath.Join(c.path, key)
if err := os.WriteFile(path, []byte(value), 0766); err != nil {
return fmt.Errorf("error writting cache file: %s", err)
}
return nil
}
func (c *FileCache) Delete(key string) error {
path := filepath.Join(c.path, key)
return os.Remove(path)
}
func NewFileCache(logger *logrus.Logger) *FileCache {
userCacheDir, err := os.UserCacheDir()
if err != nil {
logger.Fatalf("error getting cache directory: %s", err)
}
path := filepath.Join(userCacheDir, "games-screenshot-manager")
if err := os.MkdirAll(path, 0755); err != nil {
logger.Error(err)
}
return &FileCache{
logger: logger.WithField("from", "cache.file"),
path: path,
}
}

54
pkg/cache/memory.go vendored Normal file
View File

@ -0,0 +1,54 @@
package cache
import (
"sync"
"time"
"github.com/fmartingr/games-screenshot-manager/internal/models"
"github.com/sirupsen/logrus"
)
type MemoryCache struct {
logger *logrus.Entry
data map[string]string
dataMu sync.RWMutex
}
func (c *MemoryCache) Get(key string) (result string, err error) {
c.dataMu.RLock()
defer c.dataMu.RUnlock()
result, exists := c.data[key]
if !exists {
return result, models.ErrCacheKeyDontExist
}
return
}
func (c *MemoryCache) Put(key, value string) error {
c.dataMu.Lock()
c.data[key] = value
c.dataMu.Unlock()
return nil
}
func (c *MemoryCache) GetExpiry(key string, expiration time.Duration) (string, error) {
// Since this is a in-memory storage, expiration is not required as of now.
return c.Get(key)
}
func (c *MemoryCache) Delete(key string) error {
c.dataMu.Lock()
delete(c.data, key)
c.dataMu.Unlock()
return nil
}
func NewMemoryCache(logger *logrus.Logger) *MemoryCache {
return &MemoryCache{
logger: logger.WithField("from", "cache.file"),
data: make(map[string]string),
}
}

View File

@ -2,12 +2,14 @@ package helpers
import (
"crypto/md5"
"errors"
"fmt"
"io"
"log"
"os"
)
var ErrCopyFileDestinationExists = errors.New("copy destination exists")
func CopyFile(src, dst string) (int64, error) {
sourceFileStat, err := os.Stat(src)
if err != nil {
@ -26,8 +28,7 @@ func CopyFile(src, dst string) (int64, error) {
// Check if destination exists
if _, err := os.Stat(dst); !os.IsNotExist(err) {
log.Printf("- %s already exists, skipping...", dst)
return 0, nil
return 0, ErrCopyFileDestinationExists
}
destination, err := os.Create(dst)

144
pkg/processor/processor.go Normal file
View File

@ -0,0 +1,144 @@
package processor
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"sync"
"github.com/fmartingr/games-screenshot-manager/internal/models"
"github.com/fmartingr/games-screenshot-manager/pkg/helpers"
"github.com/gosimple/slug"
"github.com/sirupsen/logrus"
)
type Processor struct {
logger *logrus.Entry
options models.Options
games chan *models.Game
wg *sync.WaitGroup
}
func (p *Processor) Start(ctx context.Context) {
for i := 0; i < p.options.WorkersNum; i++ {
go p.process(ctx)
}
}
func (p *Processor) Process(game *models.Game) {
p.wg.Add(1)
p.games <- game
}
func (p *Processor) process(ctx context.Context) {
p.logger.Debug("Worker started")
for {
select {
case <-ctx.Done():
return
case game := <-p.games:
if err := p.processGame(game); err != nil {
p.logger.Errorf("Error processing game %s from %s: %s", game.Name, game.Provider, err)
}
}
}
}
func (p *Processor) Wait() {
p.wg.Wait()
}
// TODO: Reduce into smaller functions
func (p *Processor) processGame(game *models.Game) (err error) {
defer p.wg.Done()
p.logger.WithFields(logrus.Fields{
"provider": game.Provider,
"name": game.Name,
}).Debugf("Processing game")
// Do not continue if there's no screenshots
if len(game.Screenshots) == 0 {
return
}
destinationPath := filepath.Join(helpers.ExpandUser(p.options.OutputPath), game.Platform)
if len(game.Name) > 0 {
destinationPath = filepath.Join(destinationPath, game.Name)
} else {
p.logger.Warnf("found game with ID: %s from %s without a name", game.ID, game.Provider)
destinationPath = filepath.Join(destinationPath, game.ID)
}
// Check if folder exists (create otherwise)
if _, err := os.Stat(destinationPath); os.IsNotExist(err) && !p.options.DryRun {
mkdirErr := os.MkdirAll(destinationPath, 0711)
if mkdirErr != nil {
p.logger.Errorf("Couldn't create directory with name %s, falling back to %s", game.Name, slug.Make(game.Name))
destinationPath = filepath.Join(helpers.ExpandUser(p.options.OutputPath), game.Platform, slug.Make(game.Name))
os.MkdirAll(destinationPath, 0711)
}
}
if p.options.DownloadCovers && !p.options.DryRun && game.CoverURL != "" {
destinationCoverPath := filepath.Join(destinationPath, ".cover")
coverPath, err := helpers.DownloadURLIntoTempFile(game.CoverURL)
if err != nil {
p.logger.Errorf("Error donwloading cover for game %s from %s: %s", game.Name, game.Provider, err)
} else {
if _, err := os.Stat(destinationCoverPath); os.IsNotExist(err) {
helpers.CopyFile(coverPath, destinationCoverPath)
}
}
}
for _, screenshot := range game.Screenshots {
destinationPath := filepath.Join(destinationPath, screenshot.GetDestinationName())
if _, err := os.Stat(destinationPath); !os.IsNotExist(err) {
sourceMd5, err := helpers.Md5File(screenshot.Path)
if err != nil {
p.logger.Errorf("Can't get hash of source file for game %s from %s: %s", game.Name, game.Provider, err)
return err
}
destinationMd5, err := helpers.Md5File(destinationPath)
if err != nil {
p.logger.Errorf("Can't get hash of destination file for game %s from %s: %s", game.Name, game.Provider, err)
return err
}
if !bytes.Equal(sourceMd5, destinationMd5) {
// Images are not equal, we should copy it anyway, but how?
p.logger.Warnf("Found different screenshot with equal timestamp for game %s from %s on %s", game.Name, game.Provider, screenshot.Path)
}
} else {
if p.options.DryRun {
p.logger.Infof("cp %s %s", filepath.Base(screenshot.Path), strings.Replace(destinationPath, helpers.ExpandUser(p.options.OutputPath), "", 1))
} else {
if _, err := helpers.CopyFile(screenshot.Path, destinationPath); err != nil {
p.logger.WithFields(logrus.Fields{
"src": screenshot.Path,
"dest": destinationPath,
}).Errorf("Error during copy operation: %s", err)
}
}
}
}
return nil
}
func NewProcessor(logger *logrus.Logger, options models.Options) *Processor {
return &Processor{
logger: logger.WithField("from", "processor"),
games: make(chan *models.Game, options.ProcessBufferSize),
options: options,
wg: &sync.WaitGroup{},
}
}

View File

@ -0,0 +1,28 @@
package minecraft
import (
"fmt"
"io/ioutil"
"os"
"strings"
"github.com/fmartingr/games-screenshot-manager/internal/models"
"github.com/fmartingr/games-screenshot-manager/pkg/helpers"
)
func getScreenshotsFromPath(game *models.Game, path string) error {
path = helpers.ExpandUser(path)
if _, err := os.Stat(path); !os.IsNotExist(err) {
files, err := ioutil.ReadDir(path)
if err != nil {
return fmt.Errorf("error reading from %s: %s", path, err)
}
for _, file := range files {
if strings.Contains(file.Name(), ".png") {
game.Screenshots = append(game.Screenshots, models.Screenshot{Path: path + "/" + file.Name(), DestinationName: file.Name()})
}
}
}
return nil
}

View File

@ -1,53 +0,0 @@
package minecraft
import (
"io/ioutil"
"log"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/fmartingr/games-screenshot-manager/pkg/helpers"
"github.com/fmartingr/games-screenshot-manager/pkg/providers"
)
func getScreenshotsFromPath(game *providers.Game, path string) {
path = helpers.ExpandUser(path)
if _, err := os.Stat(path); !os.IsNotExist(err) {
files, err := ioutil.ReadDir(path)
if err != nil {
log.Fatal(err)
}
for _, file := range files {
if strings.Contains(file.Name(), ".png") {
game.Screenshots = append(game.Screenshots, providers.Screenshot{Path: path + "/" + file.Name(), DestinationName: file.Name()})
}
}
}
}
func GetGames(cliOptions providers.ProviderOptions) []providers.Game {
var result []providers.Game
// Standalone minecraft
minecraftStandalone := providers.Game{Name: "Minecraft", Platform: "PC", Notes: "Standalone"}
if runtime.GOOS == "linux" {
getScreenshotsFromPath(&minecraftStandalone, "~/.minecraft/screenshots")
// Flatpak minecraft
minecraftFlatpak := providers.Game{Name: "Minecraft", Platform: "PC", Notes: "Flatpak"}
for _, path := range [2]string{"~/.var/app/com.mojang.Minecraft/.minecraft/screenshots", "~/.var/app/com.mojang.Minecraft/data/minecraft/screenshots"} {
getScreenshotsFromPath(&minecraftFlatpak, path)
}
result = append(result, minecraftFlatpak)
} else if runtime.GOOS == "windows" {
getScreenshotsFromPath(&minecraftStandalone, filepath.Join(os.Getenv("APPDATA"), ".minecraft/screenshots"))
} else if runtime.GOOS == "darwin" {
getScreenshotsFromPath(&minecraftStandalone, filepath.Join(helpers.ExpandUser("~/Library/Application Support/minecraft/screenshots")))
}
result = append(result, minecraftStandalone)
return result
}

View File

@ -0,0 +1,55 @@
package minecraft
import (
"os"
"path/filepath"
"runtime"
"github.com/fmartingr/games-screenshot-manager/internal/models"
"github.com/fmartingr/games-screenshot-manager/pkg/helpers"
"github.com/sirupsen/logrus"
)
const Name = "minecraft"
type MinecraftProvider struct {
logger *logrus.Entry
}
func (p *MinecraftProvider) FindGames(options models.ProviderOptions) ([]*models.Game, error) {
var result []*models.Game
// Standalone minecraft
minecraftStandalone := models.Game{Name: "Minecraft", Platform: "PC", Notes: "Standalone"}
if runtime.GOOS == "linux" {
if err := getScreenshotsFromPath(&minecraftStandalone, "~/.minecraft/screenshots"); err != nil {
p.logger.Error(err)
}
// Flatpak minecraft
minecraftFlatpak := models.Game{Name: "Minecraft", Platform: "PC", Notes: "Flatpak"}
for _, path := range [2]string{"~/.var/app/com.mojang.Minecraft/.minecraft/screenshots", "~/.var/app/com.mojang.Minecraft/data/minecraft/screenshots"} {
if err := getScreenshotsFromPath(&minecraftFlatpak, path); err != nil {
p.logger.Error(err)
}
}
result = append(result, &minecraftFlatpak)
} else if runtime.GOOS == "windows" {
if err := getScreenshotsFromPath(&minecraftStandalone, filepath.Join(os.Getenv("APPDATA"), ".minecraft/screenshots")); err != nil {
p.logger.Error(err)
}
} else if runtime.GOOS == "darwin" {
if err := getScreenshotsFromPath(&minecraftStandalone, filepath.Join(helpers.ExpandUser("~/Library/Application Support/minecraft/screenshots"))); err != nil {
p.logger.Error(err)
}
}
result = append(result, &minecraftStandalone)
return result, nil
}
func NewMinecraftProvider(logger *logrus.Logger, cache models.Cache) models.Provider {
return &MinecraftProvider{
logger: logger.WithField("from", "provider."+Name),
}
}

View File

@ -0,0 +1,68 @@
package nintendo_switch
import (
"encoding/json"
"fmt"
"io/ioutil"
"strings"
"github.com/fmartingr/games-screenshot-manager/internal/models"
"github.com/fmartingr/games-screenshot-manager/pkg/helpers"
)
type SwitchGame struct {
Name string `json:"title_normalized"`
EncryptedGameID string `json:"encrypted_game_id"`
}
func findGameByEncryptedID(gameList []SwitchGame, encryptedGameID string) SwitchGame {
var gameFound SwitchGame = SwitchGame{EncryptedGameID: encryptedGameID}
for _, game := range gameList {
if strings.EqualFold(game.EncryptedGameID, encryptedGameID) {
gameFound = game
}
}
return gameFound
}
func getSwitchGameList() (result []SwitchGame, err error) {
response, err := helpers.DoRequest("GET", gameListURL)
if err != nil {
return nil, fmt.Errorf("error getting switch game list: %s", err)
}
if response.Body != nil {
defer response.Body.Close()
}
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("error getting switch game list: %s", err)
}
jsonErr := json.Unmarshal(body, &result)
if jsonErr != nil {
return nil, fmt.Errorf("error getting switch game list: %s", err)
}
return
}
func addScreenshotToGame(userGames []*models.Game, switchGame SwitchGame, screenshot models.Screenshot) []*models.Game {
var foundGame *models.Game
for gameIndex, game := range userGames {
if game.ID == switchGame.EncryptedGameID {
foundGame = game
userGames[gameIndex].Screenshots = append(userGames[gameIndex].Screenshots, screenshot)
}
}
if foundGame == nil {
foundGame := models.Game{Name: switchGame.Name, ID: switchGame.EncryptedGameID, Platform: platformName, Provider: platformName}
foundGame.Screenshots = append(foundGame.Screenshots, screenshot)
userGames = append(userGames, &foundGame)
}
return userGames
}

View File

@ -0,0 +1,64 @@
package nintendo_switch
import (
"os"
"path/filepath"
"strings"
"time"
"github.com/fmartingr/games-screenshot-manager/internal/models"
"github.com/sirupsen/logrus"
)
const Name = "nintendo-switch"
const platformName = "Nintendo Switch"
const gameListURL = "https://fmartingr.github.io/switch-games-json/switch_id_names.json"
type NintendoSwitchProvider struct {
logger *logrus.Entry
}
func (p *NintendoSwitchProvider) FindGames(options models.ProviderOptions) ([]*models.Game, error) {
switchGames, err := getSwitchGameList()
if err != nil {
p.logger.Error(err)
return nil, err
}
var userGames []*models.Game
err = filepath.Walk(options.InputPath,
func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
filename := filepath.Base(path)
extension := filepath.Ext(filepath.Base(path))
filenameParsed := strings.Split(filename[:len(filename)-len(extension)], "-")
switchGame := findGameByEncryptedID(switchGames, filenameParsed[1])
layout := "20060102150405"
destinationName, err := time.Parse(layout, filenameParsed[0][0:14])
if err != nil {
p.logger.Errorf("Could not parse filename '%s': %s", filename, err)
}
screenshot := models.Screenshot{Path: path, DestinationName: destinationName.Format(models.DatetimeFormat) + extension}
userGames = addScreenshotToGame(userGames, switchGame, screenshot)
}
return nil
})
if err != nil {
return nil, err
}
return userGames, nil
}
func NewNintendoSwitchProvider(logger *logrus.Logger, cache models.Cache) models.Provider {
return &NintendoSwitchProvider{
logger: logger.WithField("from", "provider."+Name),
}
}

View File

@ -1,112 +0,0 @@
package nintendo_switch
import (
"encoding/json"
"io/ioutil"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/fmartingr/games-screenshot-manager/pkg/helpers"
"github.com/fmartingr/games-screenshot-manager/pkg/providers"
)
const providerName = "nintendo-switch"
const platformName = "Nintendo Switch"
const gameListURL = "https://fmartingr.github.io/switch-games-json/switch_id_names.json"
type SwitchGame struct {
Name string `json:"title_normalized"`
EncryptedGameID string `json:"encrypted_game_id"`
}
func findGameByEncryptedID(gameList []SwitchGame, encryptedGameID string) SwitchGame {
var gameFound SwitchGame = SwitchGame{EncryptedGameID: encryptedGameID}
for _, game := range gameList {
if strings.EqualFold(game.EncryptedGameID, encryptedGameID) {
gameFound = game
}
}
return gameFound
}
func getSwitchGameList() []SwitchGame {
response, err := helpers.DoRequest("GET", gameListURL)
if err != nil {
log.Panic(err)
}
if response.Body != nil {
defer response.Body.Close()
}
body, err := ioutil.ReadAll(response.Body)
if err != nil {
log.Panic(err)
}
switchGameList := []SwitchGame{}
jsonErr := json.Unmarshal(body, &switchGameList)
if jsonErr != nil {
log.Fatal(jsonErr)
}
log.Printf("Updated Nintendo Switch game list. Found %d providers.", len(switchGameList))
return switchGameList
}
func addScreenshotToGame(userGames []providers.Game, switchGame SwitchGame, screenshot providers.Screenshot) []providers.Game {
var foundGame providers.Game
for gameIndex, game := range userGames {
if game.ID == switchGame.EncryptedGameID {
foundGame = game
userGames[gameIndex].Screenshots = append(userGames[gameIndex].Screenshots, screenshot)
}
}
if foundGame.ID == "" {
foundGame := providers.Game{Name: switchGame.Name, ID: switchGame.EncryptedGameID, Platform: platformName, Provider: providerName}
foundGame.Screenshots = append(foundGame.Screenshots, screenshot)
userGames = append(userGames, foundGame)
}
return userGames
}
func GetGames(cliOptions providers.ProviderOptions) []providers.Game {
switchGames := getSwitchGameList()
var userGames []providers.Game
err := filepath.Walk(*cliOptions.InputPath,
func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
filename := filepath.Base(path)
extension := filepath.Ext(filepath.Base(path))
filenameParsed := strings.Split(filename[:len(filename)-len(extension)], "-")
switchGame := findGameByEncryptedID(switchGames, filenameParsed[1])
layout := "20060102150405"
destinationName, err := time.Parse(layout, filenameParsed[0][0:14])
if err != nil {
log.Panic("Could not parse filename: ", err)
}
screenshot := providers.Screenshot{Path: path, DestinationName: destinationName.Format(providers.DatetimeFormat) + extension}
userGames = addScreenshotToGame(userGames, switchGame, screenshot)
}
return nil
})
if err != nil {
log.Panic(err)
}
return userGames
}

View File

@ -0,0 +1,21 @@
package playstation4
import "github.com/fmartingr/games-screenshot-manager/internal/models"
func addScreenshotToGame(userGames []*models.Game, gameName string, screenshot models.Screenshot) []*models.Game {
var foundGame *models.Game
for gameIndex, game := range userGames {
if game.Name == gameName {
foundGame = game
userGames[gameIndex].Screenshots = append(userGames[gameIndex].Screenshots, screenshot)
}
}
if foundGame == nil {
foundGame := models.Game{Name: gameName, ID: gameName, Platform: platformName, Provider: platformName}
foundGame.Screenshots = append(foundGame.Screenshots, screenshot)
userGames = append(userGames, &foundGame)
}
return userGames
}

View File

@ -1,94 +0,0 @@
package playstation4
import (
"log"
"os"
"path/filepath"
"time"
"github.com/fmartingr/games-screenshot-manager/pkg/providers"
"github.com/rwcarlsen/goexif/exif"
)
const providerName = "playstation-4"
const platformName = "PlayStation 4"
func addScreenshotToGame(userGames []providers.Game, gameName string, screenshot providers.Screenshot) []providers.Game {
var foundGame providers.Game
for gameIndex, game := range userGames {
if game.Name == gameName {
foundGame = game
userGames[gameIndex].Screenshots = append(userGames[gameIndex].Screenshots, screenshot)
}
}
// Game not found
if foundGame.Name == "" {
foundGame := providers.Game{Name: gameName, ID: gameName, Platform: platformName, Provider: providerName}
foundGame.Screenshots = append(foundGame.Screenshots, screenshot)
userGames = append(userGames, foundGame)
}
return userGames
}
func GetGames(cliOptions providers.ProviderOptions) []providers.Game {
var userGames []providers.Game
err := filepath.Walk(*cliOptions.InputPath,
func(filePath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
var destinationName string
gameName := filepath.Base(filepath.Dir(filePath))
fileName := filepath.Base(filePath)
extension := filepath.Ext(filepath.Base(filePath))
layout := "20060102150405"
if extension == ".jpg" {
fileDescriptor, errFileDescriptor := os.Open(filePath)
if errFileDescriptor != nil {
log.Printf("[warning] Couldn't open file %s: %s", fileName, errFileDescriptor)
return nil
}
exifData, errExifData := exif.Decode(fileDescriptor)
if errExifData != nil {
log.Printf("[Error] Decoding EXIF data from %s: %s", filePath, errExifData)
return nil
}
defer fileDescriptor.Close()
exifDateTime, _ := exifData.DateTime()
destinationName = exifDateTime.Format(providers.DatetimeFormat)
} else if extension == ".mp4" {
if len(fileName) >= len(layout)+len(extension) {
videoDatetime, err := time.Parse(layout, fileName[len(fileName)-len(extension)-len(layout):len(fileName)-len(extension)])
if err == nil {
destinationName = videoDatetime.Format(providers.DatetimeFormat)
} else {
log.Printf("[Warning] File does not follow datetime convention: %s. (%s) skipping...", fileName, err)
return nil
}
} else {
log.Printf("[Warning] File does not follow datetime convention: %s, skipping...", fileName)
return nil
}
}
screenshot := providers.Screenshot{Path: filePath, DestinationName: destinationName + extension}
userGames = addScreenshotToGame(userGames, gameName, screenshot)
}
return nil
})
if err != nil {
log.Panic(err)
}
return userGames
}

View File

@ -0,0 +1,84 @@
package playstation4
import (
"os"
"path/filepath"
"time"
"github.com/fmartingr/games-screenshot-manager/internal/models"
"github.com/rwcarlsen/goexif/exif"
"github.com/sirupsen/logrus"
)
const Name = "playstation-4"
const platformName = "PlayStation 4"
type Playstation4Provider struct {
logger *logrus.Entry
}
func (p *Playstation4Provider) FindGames(options models.ProviderOptions) ([]*models.Game, error) {
var userGames []*models.Game
err := filepath.Walk(options.InputPath,
func(filePath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
var destinationName string
gameName := filepath.Base(filepath.Dir(filePath))
fileName := filepath.Base(filePath)
extension := filepath.Ext(filepath.Base(filePath))
layout := "20060102150405"
if extension == ".jpg" {
fileDescriptor, errFileDescriptor := os.Open(filePath)
if errFileDescriptor != nil {
p.logger.Warnf("Couldn't open file %s: %s", fileName, errFileDescriptor)
return nil
}
exifData, errExifData := exif.Decode(fileDescriptor)
if errExifData != nil {
p.logger.Errorf("Decoding EXIF data from %s: %s", filePath, errExifData)
return nil
}
defer fileDescriptor.Close()
exifDateTime, _ := exifData.DateTime()
destinationName = exifDateTime.Format(models.DatetimeFormat)
} else if extension == ".mp4" {
if len(fileName) >= len(layout)+len(extension) {
videoDatetime, err := time.Parse(layout, fileName[len(fileName)-len(extension)-len(layout):len(fileName)-len(extension)])
if err == nil {
destinationName = videoDatetime.Format(models.DatetimeFormat)
} else {
p.logger.Warnf("File %s does not follow datetime convention, skipping.", fileName, err)
return nil
}
} else {
p.logger.Warnf("File %s does not follow datetime convention, skipping.", fileName)
return nil
}
}
screenshot := models.Screenshot{Path: filePath, DestinationName: destinationName + extension}
addScreenshotToGame(userGames, gameName, screenshot)
}
return nil
})
if err != nil {
return nil, err
}
return userGames, nil
}
func NewPlaystation4Provider(logger *logrus.Logger, cache models.Cache) models.Provider {
return &Playstation4Provider{
logger: logger.WithField("from", "provider."+Name),
}
}

View File

@ -1,21 +1,9 @@
// RetroArch screenshot provider
// Notes:
// This provider only works if the following retroarch configuration is set:
// screenshots_in_content_dir = "true"
// auto_screenshot_filename = "true"
// This way the screenshots will be stored in the same folders as the games
// We will read the playlists from retroarch to determine the Platforms and games
// from there, and screenshots will be extracted from the content folders, so you can
// sort your games the way you like most, but screenshots need to be renamed
// by retroarch for us to parse them properly.
package retroarch
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/url"
"os"
"path"
@ -23,16 +11,12 @@ import (
"strings"
"time"
"github.com/fmartingr/games-screenshot-manager/internal/models"
"github.com/fmartingr/games-screenshot-manager/pkg/helpers"
"github.com/fmartingr/games-screenshot-manager/pkg/providers"
"github.com/sirupsen/logrus"
)
const providerName = "retroarch"
const libretroCoverURLBase = "http://thumbnails.libretro.com/"
const datetimeLayout = "060102-150405"
type RetroArchPlaylistItem struct {
type retroArchPlaylistItem struct {
Path string `json:"path"`
Label string `json:"label"`
CorePath string `json:"core_path"`
@ -41,7 +25,7 @@ type RetroArchPlaylistItem struct {
DBName string `json:"db_name"`
}
type RetroArchPlaylist struct {
type retroArchPlaylist struct {
Version string `json:"version"`
DefaultCorePath string `json:"default_core_path"`
DefaultCoreName string `json:"default_core_name"`
@ -49,7 +33,7 @@ type RetroArchPlaylist struct {
RightThumbnailMode int `json:"right_thumbnail_mode"`
LeftThumbnailMode int `json:"left_thumbnail_mode"`
SortMode int `json:"sort_mode"`
Items []RetroArchPlaylistItem `json:"items"`
Items []retroArchPlaylistItem `json:"items"`
}
func formatLibretroBoxartURL(platform string, game string) string {
@ -70,32 +54,32 @@ func cleanGameName(gameName string) string {
return splits[0]
}
func readPlaylists(playlistsPath string) map[string]RetroArchPlaylist {
var result = make(map[string]RetroArchPlaylist)
func readPlaylists(logger *logrus.Entry, playlistsPath string) (map[string]retroArchPlaylist, error) {
var result = make(map[string]retroArchPlaylist)
playlistsPath = helpers.ExpandUser(playlistsPath)
if _, err := os.Stat(playlistsPath); !os.IsNotExist(err) {
files, err := ioutil.ReadDir(playlistsPath)
if err != nil {
log.Fatal(err)
return result, fmt.Errorf("error reading playlist directory: %s", err)
}
for _, file := range files {
if strings.Contains(file.Name(), ".lpl") {
var item RetroArchPlaylist
var item retroArchPlaylist
source, errOpen := os.Open(filepath.Join(playlistsPath, file.Name()))
if errOpen != nil {
log.Printf("[ERROR] Error reading playlist %s: %s", file.Name(), errOpen)
logger.Errorf("Error reading playlist %s: %s", file.Name(), errOpen)
continue
}
fileContents, errReadContent := ioutil.ReadAll(source)
if errReadContent != nil {
log.Printf("[ERROR] Reading contents of %s: %s", file.Name(), err)
logger.Errorf("Error reading contents of %s: %s", file.Name(), err)
continue
}
errUnmarshal := json.Unmarshal(fileContents, &item)
if errUnmarshal != nil {
log.Printf("[ERROR] Formatting %s: %s", file.Name(), errUnmarshal)
logger.Errorf("Error formatting %s: %s", file.Name(), errUnmarshal)
continue
}
result[strings.Replace(file.Name(), ".lpl", "", 1)] = item
@ -103,16 +87,16 @@ func readPlaylists(playlistsPath string) map[string]RetroArchPlaylist {
}
}
}
return result
return result, nil
}
func findScreenshotsForGame(item RetroArchPlaylistItem) []providers.Screenshot {
var result []providers.Screenshot
func findScreenshotsForGame(logger *logrus.Entry, item retroArchPlaylistItem) ([]models.Screenshot, error) {
var result []models.Screenshot
filePath := filepath.Dir(item.Path)
fileName := strings.Replace(filepath.Base(item.Path), filepath.Ext(item.Path), "", 1)
files, err := ioutil.ReadDir(filePath)
if err != nil {
log.Fatal(err)
return nil, err
}
for _, file := range files {
@ -130,51 +114,19 @@ func findScreenshotsForGame(item RetroArchPlaylistItem) []providers.Screenshot {
if strings.Contains(file.Name(), "-cheevo-") {
filenameParts := strings.Split(file.Name(), "-")
achievementID := strings.Replace(filenameParts[len(filenameParts)-1], extension, "", 1)
screenshotDestinationName = file.ModTime().Format(providers.DatetimeFormat) + "_retroachievement-" + achievementID + extension
screenshotDestinationName = file.ModTime().Format(models.DatetimeFormat) + "_retroachievement-" + achievementID + extension
} else {
screenshotDate, err := time.Parse(datetimeLayout, file.Name()[len(file.Name())-len(extension)-len(datetimeLayout):len(file.Name())-len(extension)])
if err == nil {
screenshotDestinationName = screenshotDate.Format(providers.DatetimeFormat) + extension
screenshotDestinationName = screenshotDate.Format(models.DatetimeFormat) + extension
} else {
log.Printf("[error] Formatting screenshot %s: %s", file.Name(), err)
logger.Errorf("Error formatting screenshot %s: %s", file.Name(), err)
continue
}
}
result = append(result, providers.Screenshot{Path: filepath.Join(filePath, file.Name()), DestinationName: screenshotDestinationName})
result = append(result, models.Screenshot{Path: filepath.Join(filePath, file.Name()), DestinationName: screenshotDestinationName})
}
}
return result
}
func GetGames(cliOptions providers.ProviderOptions) []providers.Game {
var userGames []providers.Game
playlists := readPlaylists(*cliOptions.InputPath)
for playlistName := range playlists {
for _, item := range playlists[playlistName].Items {
var cover providers.Screenshot
if *cliOptions.DownloadCovers {
coverURL := formatLibretroBoxartURL(playlistName, item.Label)
boxartPath, err := helpers.DownloadURLIntoTempFile(coverURL)
if err == nil {
cover = providers.Screenshot{Path: boxartPath, DestinationName: ".cover"}
} else {
log.Printf("[error] Error downloading cover for %s: %s", item.Label, err)
}
}
userGames = append(userGames, providers.Game{
Platform: cleanPlatformName(playlistName),
Name: cleanGameName(item.Label),
Provider: providerName,
Screenshots: findScreenshotsForGame(item),
Cover: cover,
})
}
}
return userGames
return result, nil
}

View File

@ -0,0 +1,62 @@
// RetroArch screenshot provider
// Notes:
// This provider only works if the following retroarch configuration is set:
// screenshots_in_content_dir = "true"
// auto_screenshot_filename = "true"
// This way the screenshots will be stored in the same folders as the games
// We will read the playlists from retroarch to determine the Platforms and games
// from there, and screenshots will be extracted from the content folders, so you can
// sort your games the way you like most, but screenshots need to be renamed
// by retroarch for us to parse them properly.
package retroarch
import (
"github.com/fmartingr/games-screenshot-manager/internal/models"
"github.com/sirupsen/logrus"
)
const Name = "retroarch"
const libretroCoverURLBase = "http://thumbnails.libretro.com/"
const datetimeLayout = "060102-150405"
type RetroArchProvider struct {
logger *logrus.Entry
}
func (p *RetroArchProvider) FindGames(options models.ProviderOptions) ([]*models.Game, error) {
var userGames []*models.Game
playlists, err := readPlaylists(p.logger, options.InputPath)
if err != nil {
return nil, err
}
for playlistName := range playlists {
for _, item := range playlists[playlistName].Items {
screenshots, err := findScreenshotsForGame(p.logger, item)
if err != nil {
p.logger.Errorf("Error retrieving game screenshots: %s", err)
continue
}
userGames = append(userGames, &models.Game{
Platform: cleanPlatformName(playlistName),
Name: cleanGameName(item.Label),
Provider: Name,
Screenshots: screenshots,
CoverURL: formatLibretroBoxartURL(playlistName, item.Label),
})
}
}
return userGames, nil
}
func NewRetroArchProvider(logger *logrus.Logger, cache models.Cache) models.Provider {
return &RetroArchProvider{
logger: logger.WithField("from", "provider."+Name),
}
}

View File

@ -0,0 +1,135 @@
package steam
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/fmartingr/games-screenshot-manager/internal/models"
"github.com/fmartingr/games-screenshot-manager/pkg/helpers"
"github.com/sirupsen/logrus"
)
func getBasePathForOS() (string, error) {
var path string
switch runtime.GOOS {
case "darwin":
path = helpers.ExpandUser("~/Library/Application Support/Steam")
case "linux":
path = helpers.ExpandUser("~/.local/share/Steam")
case "windows":
path = "C:\\Program Files (x86)\\Steam"
default:
return "", fmt.Errorf("unsupported os: %s", runtime.GOOS)
}
return path, nil
}
func getSteamAppList(logger *logrus.Entry, cache models.Cache, c chan SteamAppList) {
defer close(c)
cacheKey := "steam-applist"
download := true
var payload []byte
result, err := cache.GetExpiry(cacheKey, 24*time.Hour)
if err != nil && !errors.Is(err, models.ErrCacheKeyDontExist) {
logger.Errorf("error retrieving cache: %s", err)
return
}
if len(result) > 0 {
download = false
payload = []byte(result)
}
if download {
response, err := helpers.DoRequest("GET", gameListURL)
if err != nil {
logger.Errorf("Error making request for Steam APP List: %s", err)
}
if response.Body != nil {
defer response.Body.Close()
}
payload, err = ioutil.ReadAll(response.Body)
if err != nil {
logger.Errorf("Error reading steam response: %s", err)
}
}
if download {
if err := cache.Put(cacheKey, string(payload)); err != nil {
logger.Error(err)
}
}
steamListResponse := SteamAppListResponse{}
jsonErr := json.Unmarshal(payload, &steamListResponse)
if jsonErr != nil {
logger.Errorf("Error unmarshalling steam's response: %s", jsonErr)
}
c <- steamListResponse.AppList
}
func guessUsers(basePath string) ([]string, error) {
var users []string
var path string = filepath.Join(basePath, "userdata")
if _, err := os.Stat(path); !os.IsNotExist(err) {
files, err := ioutil.ReadDir(path)
if err != nil {
return nil, err
}
for _, file := range files {
if _, err := strconv.ParseInt(file.Name(), 10, 64); err == nil {
users = append(users, file.Name())
}
}
}
return users, nil
}
func getGamesFromUser(basePath, user string) ([]string, error) {
var userGames []string
var path string = filepath.Join(basePath, "userdata", user, "760", "remote")
if _, err := os.Stat(path); !os.IsNotExist(err) {
files, err := ioutil.ReadDir(path)
if err != nil {
return nil, err
}
for _, file := range files {
// len(file.Name()) == 20 -> Custom added Game to steam
userGames = append(userGames, file.Name())
}
}
return userGames, nil
}
func getScreenshotsForGame(basePath, user string, game *models.Game) error {
path := filepath.Join(basePath, "userdata", user, "/760/remote/", game.ID, "screenshots")
files, err := ioutil.ReadDir(path)
if err != nil {
return fmt.Errorf("error reading game screenshot path: %s", err)
}
for _, file := range files {
if strings.Contains(file.Name(), ".jpg") {
game.Screenshots = append(game.Screenshots, models.NewScreenshotWithoutDestination(path+"/"+file.Name()))
}
}
return nil
}

View File

@ -0,0 +1,102 @@
package steam
import (
"errors"
"fmt"
"strconv"
"github.com/fmartingr/games-screenshot-manager/internal/models"
"github.com/sirupsen/logrus"
)
const Name = "steam"
const gameListURL = "https://api.steampowered.com/ISteamApps/GetAppList/v2/"
const baseGameHeaderURL = "https://cdn.cloudflare.steamstatic.com/steam/apps/%d/header.jpg"
var errGameIDNotFound = errors.New("game ID not found")
type SteamApp struct {
AppID uint64 `json:"appid"`
Name string `json:"name"`
}
type SteamAppList struct {
Apps []SteamApp `json:"apps"`
}
func (appList SteamAppList) FindID(id string) (result SteamApp, err error) {
for _, game := range appList.Apps {
uintGameID, err := strconv.ParseUint(id, 10, 64)
if err != nil {
return result, fmt.Errorf("error parsing game ID: %s", err)
}
if game.AppID == uintGameID {
return game, nil
}
}
return result, errGameIDNotFound
}
type SteamAppListResponse struct {
AppList SteamAppList `json:"applist"`
}
type SteamProvider struct {
logger *logrus.Entry
cache models.Cache
}
func (p *SteamProvider) FindGames(options models.ProviderOptions) ([]*models.Game, error) {
basePath, err := getBasePathForOS()
if err != nil {
return nil, fmt.Errorf("error getting steam's base path: %s", err)
}
var localGames []*models.Game
c := make(chan SteamAppList)
go getSteamAppList(p.logger, p.cache, c)
users, err := guessUsers(basePath)
if err != nil {
return nil, fmt.Errorf("error getting users: %s", err)
}
p.logger.Debugf("Found %d users", len(users))
steamApps := <-c
if len(steamApps.Apps) == 0 {
return nil, fmt.Errorf("coulnd't get steam app list")
}
for _, userID := range users {
userGames, err := getGamesFromUser(basePath, userID)
if err != nil {
p.logger.Errorf("error retrieving user's %s games: %s", userID, err)
continue
}
for _, userGameID := range userGames {
steamGame, err := steamApps.FindID(userGameID)
if err != nil {
p.logger.Errorf("Steam game ID not found: %s", userGameID)
}
p.logger.WithField("userID", userID).Debugf("Found game: %s", steamGame.Name)
userGame := models.NewGame(userGameID, steamGame.Name, "PC", Name)
userGame.CoverURL = fmt.Sprintf(baseGameHeaderURL, steamGame.AppID)
if err := getScreenshotsForGame(basePath, userID, &userGame); err != nil {
p.logger.Errorf("error getting screenshots: %s", err)
}
localGames = append(localGames, &userGame)
}
}
return localGames, nil
}
func NewSteamProvider(logger *logrus.Logger, cache models.Cache) models.Provider {
return &SteamProvider{
cache: cache,
logger: logger.WithField("from", "provider."+Name),
}
}

View File

@ -1,188 +0,0 @@
package steam
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/fmartingr/games-screenshot-manager/pkg/helpers"
"github.com/fmartingr/games-screenshot-manager/pkg/providers"
)
const providerName string = "steam"
const gameListURL string = "https://api.steampowered.com/ISteamApps/GetAppList/v2/"
const baseGameHeaderURL string = "https://cdn.cloudflare.steamstatic.com/steam/apps/%s/header.jpg"
type SteamApp struct {
AppID uint64 `json:"appid"`
Name string `json:"name"`
}
type SteamAppList struct {
Apps []SteamApp `json:"apps"`
}
func (appList SteamAppList) FindID(id string) (SteamApp, error) {
GameIDNotFound := errors.New("game ID not found")
for _, game := range appList.Apps {
uintGameID, err := strconv.ParseUint(id, 10, 64)
if err != nil {
log.Panic(err)
}
if game.AppID == uintGameID {
return game, nil
}
}
return SteamApp{}, GameIDNotFound
}
type SteamAppListResponse struct {
AppList SteamAppList `json:"applist"`
}
func getBasePathForOS() string {
var path string
switch runtime.GOOS {
case "darwin":
path = helpers.ExpandUser("~/Library/Application Support/Steam")
case "linux":
path = helpers.ExpandUser("~/.local/share/Steam")
case "windows":
path = "C:\\Program Files (x86)\\Steam"
default:
log.Panic("Unsupported OS: ", runtime.GOOS)
}
return path
}
func getSteamAppList(c chan SteamAppList) {
log.Println("Updating steam game list...")
response, err := helpers.DoRequest("GET", gameListURL)
if err != nil {
panic(err)
}
if response.Body != nil {
defer response.Body.Close()
}
body, err := ioutil.ReadAll(response.Body)
if err != nil {
panic(err)
}
steamListResponse := SteamAppListResponse{}
jsonErr := json.Unmarshal(body, &steamListResponse)
if jsonErr != nil {
log.Fatal(jsonErr)
}
log.Printf("Updated Steam game list. Found %d apps.", len(steamListResponse.AppList.Apps))
c <- steamListResponse.AppList
}
func downloadGameHeaderImage(appId string) (string, error) {
url := fmt.Sprintf(baseGameHeaderURL, appId)
coverURL, err := helpers.DownloadURLIntoTempFile(url)
if err != nil {
return "", err
}
return coverURL, nil
}
func GuessUsers() []string {
var users []string
var path string = filepath.Join(getBasePathForOS(), "userdata")
if _, err := os.Stat(path); !os.IsNotExist(err) {
files, err := ioutil.ReadDir(path)
if err != nil {
log.Fatal(err)
}
for _, file := range files {
if _, err := strconv.ParseInt(file.Name(), 10, 64); err == nil {
log.Printf("Found local install Steam user: %s", file.Name())
users = append(users, file.Name())
}
}
}
return users
}
func GetGamesFromUser(user string) []string {
log.Println("Getting Steam games for user: " + user)
var userGames []string
var path string = filepath.Join(getBasePathForOS(), "userdata", user, "760", "remote")
if _, err := os.Stat(path); !os.IsNotExist(err) {
files, err := ioutil.ReadDir(path)
if err != nil {
log.Fatal(err)
}
for _, file := range files {
// len(file.Name()) == 20 -> Custom added Game to steam
userGames = append(userGames, file.Name())
}
}
return userGames
}
func GetScreenshotsForGame(user string, game *providers.Game) {
path := filepath.Join(getBasePathForOS(), "userdata", user, "/760/remote/", game.ID, "screenshots")
files, err := ioutil.ReadDir(path)
if err != nil {
log.Fatal(err)
}
for _, file := range files {
if strings.Contains(file.Name(), ".jpg") {
game.Screenshots = append(game.Screenshots, providers.Screenshot{Path: path + "/" + file.Name()})
// log.Printf("Found screenshot for user %s and game %d: %s", user, game.ID, path+"/"+file.Name())
}
}
if len(game.Screenshots) > 0 {
log.Printf("Found %d screenshots", len(game.Screenshots))
}
}
func GetGames(cliOptions providers.ProviderOptions) []providers.Game {
var localGames []providers.Game
c := make(chan SteamAppList)
go getSteamAppList(c)
users := GuessUsers()
steamApps := <-c
for _, userID := range users {
userGames := GetGamesFromUser(userID)
for _, userGameID := range userGames {
steamGame, err := steamApps.FindID(userGameID)
if err != nil {
log.Print("[ERROR] Steam game ID not found: ", userGameID)
}
userGame := providers.Game{ID: userGameID, Name: steamGame.Name, Provider: providerName, Platform: "PC"}
if *cliOptions.DownloadCovers {
coverPath, err := downloadGameHeaderImage(userGameID)
if err == nil {
userGame.Cover = providers.Screenshot{Path: coverPath, DestinationName: ".cover"}
}
}
log.Printf("Found Steam game for user %s: %s (%s)", userID, userGame.Name, userGame.ID)
GetScreenshotsForGame(userID, &userGame)
localGames = append(localGames, userGame)
}
}
return localGames
}

46
pkg/registry/registry.go Normal file
View File

@ -0,0 +1,46 @@
package registry
import (
"errors"
"github.com/fmartingr/games-screenshot-manager/internal/models"
"github.com/sirupsen/logrus"
)
var ErrProviderAlreadyRegistered = errors.New("provider already registered")
var ErrProviderNotRegistered = errors.New("provider not registered")
type ProviderRegistry struct {
logger *logrus.Entry
cache models.Cache
providers map[string]*models.Provider
}
func (r *ProviderRegistry) Register(name string, providerFactory models.ProviderFactory) error {
_, exists := r.providers[name]
if exists {
return ErrProviderAlreadyRegistered
}
provider := providerFactory(r.logger.Logger, r.cache)
r.providers[name] = &provider
return nil
}
func (r *ProviderRegistry) Get(providerName string) (models.Provider, error) {
provider, exists := r.providers[providerName]
if !exists {
return nil, ErrProviderNotRegistered
}
return *provider, nil
}
func NewProviderRegistry(logger *logrus.Logger, cache models.Cache) *ProviderRegistry {
return &ProviderRegistry{
logger: logger.WithField("from", "registry"),
cache: cache,
providers: make(map[string]*models.Provider),
}
}