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
This commit is contained in:
Felipe M 2022-01-23 21:47:18 +01:00 committed by Felipe Martin Garcia
parent 0d21736081
commit 01e3906a9a
19 changed files with 725 additions and 554 deletions

2
.gitignore vendored
View File

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

View File

@ -1,25 +1,20 @@
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/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"
)
var allowedProviders = [...]string{"steam", "minecraft", "nintendo-switch", "playstation-4", "retroarch"}
const defaultOutputPath string = "./Output"
const defaultInputPath string = ""
@ -28,104 +23,49 @@ 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"),
registry := registry.NewProviderRegistry()
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{
OutputPath: *flag.String("output-path", defaultOutputPath, "The destination path of the screenshots"),
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"),
ProcessBufferSize: 0, // Unbuffered for now
}
var providerName = flag.String("provider", defaultProvider, "steam")
providerOptions := models.ProviderOptions{
InputPath: *flag.String("input-path", defaultInputPath, "Input path for the provider that requires it"),
}
var provider = flag.String("provider", defaultProvider, "steam")
flag.Parse()
if helpers.SliceContainsString(allowedProviders[:], *provider, nil) {
games := getGamesFromProvider(*provider, cliOptions)
processGames(games, cliOptions)
} else {
log.Printf("Provider %s not found!", *provider)
provider, err := registry.Get(*providerName)
if err != nil {
log.Printf("Provider %s not found!", *providerName)
return
}
}
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)
}
}
}
games, err := provider.FindGames(providerOptions)
if err != nil {
log.Println(err)
return
}
ctx, cancel := context.WithCancel(context.Background())
processor := processor.NewProcessor(options)
if len(games) > 0 {
processor.Start(ctx)
for _, g := range games {
processor.Process(g)
}
processor.Wait()
}
cancel()
}

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,16 @@
package models
type Options struct {
OutputPath string
DryRun bool
DownloadCovers bool
ProcessBufferSize int
}
type ProviderOptions struct {
InputPath string
}
type Provider interface {
FindGames(options ProviderOptions) ([]*Game, error)
}

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

@ -0,0 +1,127 @@
package processor
import (
"bytes"
"context"
"log"
"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"
)
type Processor struct {
input chan *models.Game
options models.Options
wg sync.WaitGroup
}
func (p *Processor) Start(ctx context.Context) {
go p.process(ctx)
}
func (p *Processor) Process(game *models.Game) {
p.input <- game
}
func (p *Processor) process(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case game := <-p.input:
p.wg.Add(1)
if err := p.processGame(game); err != nil {
log.Printf("[err] %s", err)
}
}
}
}
func (p *Processor) Wait() {
p.wg.Wait()
}
func NewProcessor(options models.Options) *Processor {
return &Processor{
input: make(chan *models.Game, options.ProcessBufferSize),
options: options,
wg: sync.WaitGroup{},
}
}
// TODO: Reduce into smaller functions
func (p *Processor) processGame(game *models.Game) (err error) {
defer p.wg.Done()
// 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 {
log.Printf("[IMPORTANT] Game ID %s has no name!", game.ID)
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 {
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(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 {
log.Printf("[error] Error donwloading cover: %s", err)
}
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 {
log.Fatal(err)
return err
}
destinationMd5, err := helpers.Md5File(destinationPath)
if err != nil {
log.Fatal(err)
return err
}
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 p.options.DryRun {
log.Println(filepath.Base(screenshot.Path), " -> ", strings.Replace(destinationPath, helpers.ExpandUser(p.options.OutputPath), "", 1))
} else {
helpers.CopyFile(screenshot.Path, destinationPath)
}
}
}
return nil
}

View File

@ -0,0 +1,27 @@
package minecraft
import (
"io/ioutil"
"log"
"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) {
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, models.Screenshot{Path: path + "/" + file.Name(), DestinationName: file.Name()})
}
}
}
}

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,42 @@
package minecraft
import (
"os"
"path/filepath"
"runtime"
"github.com/fmartingr/games-screenshot-manager/internal/models"
"github.com/fmartingr/games-screenshot-manager/pkg/helpers"
)
const Name = "minecraft"
type MinecraftProvider struct{}
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" {
getScreenshotsFromPath(&minecraftStandalone, "~/.minecraft/screenshots")
// 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"} {
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, nil
}
func NewMinecraftProvider() *MinecraftProvider {
return &MinecraftProvider{}
}

View File

@ -0,0 +1,71 @@
package nintendo_switch
import (
"encoding/json"
"io/ioutil"
"log"
"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() []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 games.", len(switchGameList))
return switchGameList
}
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.ID == "" {
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,55 @@
package nintendo_switch
import (
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/fmartingr/games-screenshot-manager/internal/models"
)
const Name = "nintendo-switch"
const platformName = "Nintendo Switch"
const gameListURL = "https://fmartingr.github.io/switch-games-json/switch_id_names.json"
type NintendoSwitchProvider struct{}
func (p *NintendoSwitchProvider) FindGames(options models.ProviderOptions) ([]*models.Game, error) {
switchGames := getSwitchGameList()
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 {
log.Panic("Could not parse filename: ", err)
}
screenshot := models.Screenshot{Path: path, DestinationName: destinationName.Format(models.DatetimeFormat) + extension}
userGames = addScreenshotToGame(userGames, switchGame, screenshot)
}
return nil
})
if err != nil {
log.Panic(err)
}
return userGames, nil
}
func NewNintendoSwitchProvider() *NintendoSwitchProvider {
return &NintendoSwitchProvider{}
}

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 games.", 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.ID == "" {
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

@ -6,36 +6,19 @@ import (
"path/filepath"
"time"
"github.com/fmartingr/games-screenshot-manager/pkg/providers"
"github.com/fmartingr/games-screenshot-manager/internal/models"
"github.com/rwcarlsen/goexif/exif"
)
const providerName = "playstation-4"
const Name = "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)
}
}
type Playstation4Provider struct{}
// 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)
}
func (p *Playstation4Provider) FindGames(options models.ProviderOptions) ([]*models.Game, error) {
var userGames []*models.Game
return userGames
}
func GetGames(cliOptions providers.ProviderOptions) []providers.Game {
var userGames []providers.Game
err := filepath.Walk(*cliOptions.InputPath,
err := filepath.Walk(options.InputPath,
func(filePath string, info os.FileInfo, err error) error {
if err != nil {
return err
@ -62,14 +45,14 @@ func GetGames(cliOptions providers.ProviderOptions) []providers.Game {
defer fileDescriptor.Close()
exifDateTime, _ := exifData.DateTime()
destinationName = exifDateTime.Format(providers.DatetimeFormat)
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(providers.DatetimeFormat)
destinationName = videoDatetime.Format(models.DatetimeFormat)
} else {
log.Printf("[Warning] File does not follow datetime convention: %s. (%s) skipping...", fileName, err)
return nil
@ -80,9 +63,8 @@ func GetGames(cliOptions providers.ProviderOptions) []providers.Game {
}
}
screenshot := providers.Screenshot{Path: filePath, DestinationName: destinationName + extension}
userGames = addScreenshotToGame(userGames, gameName, screenshot)
screenshot := models.Screenshot{Path: filePath, DestinationName: destinationName + extension}
addScreenshotToGame(userGames, gameName, screenshot)
}
return nil
@ -90,5 +72,9 @@ func GetGames(cliOptions providers.ProviderOptions) []providers.Game {
if err != nil {
log.Panic(err)
}
return userGames
return userGames, nil
}
func NewPlaystation4Provider() *Playstation4Provider {
return &Playstation4Provider{}
}

View File

@ -1,15 +1,3 @@
// 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 (
@ -23,16 +11,11 @@ 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"
)
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 +24,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 +32,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,8 +53,8 @@ func cleanGameName(gameName string) string {
return splits[0]
}
func readPlaylists(playlistsPath string) map[string]RetroArchPlaylist {
var result = make(map[string]RetroArchPlaylist)
func readPlaylists(playlistsPath string) map[string]retroArchPlaylist {
var result = make(map[string]retroArchPlaylist)
playlistsPath = helpers.ExpandUser(playlistsPath)
if _, err := os.Stat(playlistsPath); !os.IsNotExist(err) {
files, err := ioutil.ReadDir(playlistsPath)
@ -81,7 +64,7 @@ func readPlaylists(playlistsPath string) map[string]RetroArchPlaylist {
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)
@ -106,8 +89,8 @@ func readPlaylists(playlistsPath string) map[string]RetroArchPlaylist {
return result
}
func findScreenshotsForGame(item RetroArchPlaylistItem) []providers.Screenshot {
var result []providers.Screenshot
func findScreenshotsForGame(item retroArchPlaylistItem) []models.Screenshot {
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)
@ -130,51 +113,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)
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
}

View File

@ -0,0 +1,49 @@
// 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"
)
const Name = "retroarch"
const libretroCoverURLBase = "http://thumbnails.libretro.com/"
const datetimeLayout = "060102-150405"
type RetroArchProvider struct {
}
func (p *RetroArchProvider) FindGames(options models.ProviderOptions) ([]*models.Game, error) {
var userGames []*models.Game
playlists := readPlaylists(options.InputPath)
for playlistName := range playlists {
for _, item := range playlists[playlistName].Items {
userGames = append(userGames, &models.Game{
Platform: cleanPlatformName(playlistName),
Name: cleanGameName(item.Label),
Provider: Name,
Screenshots: findScreenshotsForGame(item),
CoverURL: formatLibretroBoxartURL(playlistName, item.Label),
})
}
}
return userGames, nil
}
func NewRetroArchProvider() *RetroArchProvider {
return &RetroArchProvider{}
}

View File

@ -0,0 +1,111 @@
package steam
import (
"encoding/json"
"io/ioutil"
"log"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/fmartingr/games-screenshot-manager/internal/models"
"github.com/fmartingr/games-screenshot-manager/pkg/helpers"
)
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 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 *models.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, models.NewScreenshotWithoutDestination(path+"/"+file.Name()))
}
}
}

View File

@ -0,0 +1,72 @@
package steam
import (
"errors"
"fmt"
"log"
"strconv"
"github.com/fmartingr/games-screenshot-manager/internal/models"
)
const Name = "steam"
const gameListURL = "https://api.steampowered.com/ISteamApps/GetAppList/v2/"
const baseGameHeaderURL = "https://cdn.cloudflare.steamstatic.com/steam/apps/%d/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"`
}
type SteamProvider struct{}
func (p *SteamProvider) FindGames(options models.ProviderOptions) ([]*models.Game, error) {
var localGames []*models.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 := models.NewGame(userGameID, steamGame.Name, "PC", Name)
userGame.CoverURL = fmt.Sprintf(baseGameHeaderURL, steamGame.AppID)
log.Printf("Found Steam game for user %s: %s (%s)", userID, userGame.Name, userGame.ID)
getScreenshotsForGame(userID, &userGame)
localGames = append(localGames, &userGame)
}
}
return localGames, nil
}
func NewSteamProvider() *SteamProvider {
return &SteamProvider{}
}

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
}

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

@ -0,0 +1,39 @@
package registry
import (
"errors"
"github.com/fmartingr/games-screenshot-manager/internal/models"
)
var ErrProviderAlreadyRegistered = errors.New("provider already registered")
var ErrProviderNotRegistered = errors.New("provider not registered")
type ProviderRegistry struct {
providers map[string]*models.Provider
}
func (r *ProviderRegistry) Register(name string, provider models.Provider) error {
_, exists := r.providers[name]
if exists {
return ErrProviderAlreadyRegistered
}
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() *ProviderRegistry {
return &ProviderRegistry{
providers: make(map[string]*models.Provider),
}
}