Compare commits

..

12 Commits

9 changed files with 11846 additions and 11563 deletions

12
README.md Normal file
View File

@@ -0,0 +1,12 @@
# OPL-CLI
Manage your [OPL](https://github.com/ps2homebrew/Open-PS2-Loader) library from the command line.
# Usage
There are 4 subcommands provided:
- `scan` Scan PS2 ISOs and determine the game ID and name.
- `rename` Rename the ISO based on the scanned name.
- `art` Download game art like cover and screenshots for the game.
- `cfg` Download base configuration for the game.

View File

@@ -6,26 +6,65 @@ import (
) )
type ScanCmd struct { type ScanCmd struct {
File string `arg:"positional"` Files []string `arg:"positional,required"`
} }
type RenameCmd struct { type RenameCmd struct {
File string `arg:"positional"` Files []string `arg:"positional,required"`
}
type ArtCmd struct {
Files []string `arg:"positional,required"`
OutputDirectory string `arg:"--output,-o" help:"where to download art files" default:"."`
}
type CfgCmd struct {
Files []string `arg:"positional,required"`
OutputDirectory string `arg:"--output,-o" help:"where to download cfg files" default:"."`
Override bool `arg:"-f,--force" help:"Override existing cfg files"`
Append bool `arg:"-a,--append" help:"Append to existing cfg files"`
} }
type args struct { type args struct {
Scan *ScanCmd `arg:"subcommand:scan"` Scan *ScanCmd `arg:"subcommand:scan"`
Rename *RenameCmd `arg:"subcommand:rename"` Rename *RenameCmd `arg:"subcommand:rename"`
Art *ArtCmd `arg:"subcommand:art"`
Cfg *CfgCmd `arg:"subcommand:cfg"`
GameListLocation string `args:"--gamelist" default:"./ps2-gameslist.txt" help:"Location of game list"`
} }
func Run() { func Run() {
var args args var args args
arg.MustParse(&args) p := arg.MustParse(&args)
config := oplcli.Config{
GameListLocation: args.GameListLocation,
}
switch { switch {
case args.Scan != nil: case args.Scan != nil:
oplcli.Scan(args.Scan.File, oplcli.Config{}) oplcli.Scan(args.Scan.Files, config)
case args.Rename != nil: case args.Rename != nil:
oplcli.Rename(args.Rename.File, oplcli.Config{}) oplcli.Rename(args.Rename.Files, config)
case args.Art != nil:
oplcli.DownloadGameArt(args.Art.Files, args.Art.OutputDirectory, config)
case args.Cfg != nil:
if args.Cfg.Override && args.Cfg.Append {
p.FailSubcommand("Can only append or override cfg files", "cfg")
}
var mode oplcli.FileMode = oplcli.ModeOnlyCreate
if args.Cfg.Append {
mode = oplcli.ModeAppend
}
if args.Cfg.Override {
mode = oplcli.ModeOverride
}
oplcli.DownloadCfg(args.Cfg.Files, args.Cfg.OutputDirectory, mode, config)
default:
p.Fail("Must specify command")
} }
} }

105
internal/art.go Normal file
View File

@@ -0,0 +1,105 @@
package oplcli
import (
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path"
)
type artUrls struct {
cover string
cover2 string
background string
disc string
spine string
logo string
screenshot string
screenshot2 string
}
func getArtUrlForGameFromGithub(gameID string) artUrls {
const baseURL = "https://raw.githubusercontent.com/Luden02/psx-ps2-opl-art-database/refs/heads/main/PS2"
genURL := func(kind string) string {
return fmt.Sprintf("%s/%s/%s_%s.png", baseURL, gameID, gameID, kind)
}
return artUrls{
cover: genURL("COV"),
cover2: genURL("COV2"),
background: genURL("BG_00"),
disc: genURL("ICO"),
spine: genURL("LAB"),
logo: genURL("LGO"),
screenshot: genURL("SCR_00"),
screenshot2: genURL("SCR_01"),
}
}
func downloadArtForGame(gameID string, targetDir string) {
urls := getArtUrlForGameFromGithub(gameID)
totalDownloads := 0
dl := func(url, kind string) {
file := path.Join(targetDir, fmt.Sprintf("%s_%s.png", gameID, kind))
found, err := downloadFile(url, file)
if err != nil {
slog.Warn("Failed to download art file", "gameID", gameID, "url", url)
}
if found {
slog.Debug("Downloaded art file", "gameID", gameID, "kind", kind, "target", file)
totalDownloads++
}
}
dl(urls.cover, "COV")
dl(urls.cover2, "COV2")
dl(urls.disc, "ICO")
dl(urls.background, "BG")
dl(urls.screenshot, "SCR")
dl(urls.screenshot2, "SRC2")
dl(urls.spine, "LAB")
if totalDownloads == 0 {
slog.Error("No art files found", "gameID", gameID)
} else {
slog.Info("Finished downloading art files", "gameID", gameID, "total", totalDownloads)
}
}
func downloadFile(url, target string) (bool, error) {
out, err := os.Create(target)
if err != nil {
return false, err
}
defer out.Close()
resp, err := http.Get(url)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return false, nil
}
if resp.StatusCode >= 400 {
return false, fmt.Errorf("status code: %s", resp.Status)
}
n, err := io.Copy(out, resp.Body)
if err != nil {
return false, err
}
if n == 0 {
return false, fmt.Errorf("Got empty file")
}
return true, nil
}

85
internal/cfg.go Normal file
View File

@@ -0,0 +1,85 @@
package oplcli
import (
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path"
)
type FileMode int
const (
ModeOverride FileMode = iota
ModeAppend
ModeOnlyCreate
)
func downloadCfgForGame(gameID string, output string, mode FileMode) error {
url := getDownloadURLFromGithub(gameID)
filePath := path.Join(output, fmt.Sprintf("%s.cfg", gameID))
file, err := openFileBasedOnMode(filePath, mode)
if err != nil {
slog.Error("Failed to open cfg file", "file", filePath, "gameID", gameID, "err", err)
return err
}
defer file.Close()
resp, err := http.Get(url)
if err != nil {
slog.Error("Failed to download cfg file", "file", filePath, "gameID", gameID, "err", err)
return err
}
defer resp.Body.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
slog.Error("Failed to write cfg file", "file", filePath, "gameID", gameID, "err", err)
return err
}
return nil
}
func openFileBasedOnMode(filePath string, mode FileMode) (*os.File, error) {
_, err := os.Stat(filePath)
if err != nil {
if err != os.ErrNotExist {
file, err := os.Create(filePath)
if err != nil {
return nil, err
}
return file, nil
}
}
if mode == ModeOverride {
file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
return nil, err
}
return file, nil
}
if mode == ModeAppend {
file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
return nil, err
}
return file, nil
}
return nil, fmt.Errorf("File already exists")
}
func getDownloadURLFromGithub(gameID string) string {
// const baseURL = "https://raw.githubusercontent.com/VTSTech/PS2-OPL-CFG/refs/heads/master/CFG/" // This one is more basic
const baseURL = "https://raw.githubusercontent.com/Tom-Bruise/PS2-OPL-CFG-Database/refs/heads/master/CFG_en/" // This one contains descriptions and more
return fmt.Sprintf("%s/%s.cfg", baseURL, gameID)
}

View File

@@ -2,6 +2,7 @@ package oplcli
import ( import (
"fmt" "fmt"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@@ -11,7 +12,8 @@ import (
func scanGameFileForID(filename string) (string, error) { func scanGameFileForID(filename string) (string, error) {
file, err := os.Open(filename) file, err := os.Open(filename)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to open file: %w", err) slog.Error("Failed to open file", "file", filename, "err", err)
return "", err
} }
defer file.Close() defer file.Close()
@@ -20,11 +22,13 @@ func scanGameFileForID(filename string) (string, error) {
n, err := file.Read(buffer) n, err := file.Read(buffer)
if err != nil && n == 0 { if err != nil && n == 0 {
return "", fmt.Errorf("failed to read file: %w", err) slog.Error("Failed to read file", "file", file, "err", err)
return "", err
} }
id, found := scanBufferForID(buffer[:n]) id, found := scanBufferForID(buffer[:n])
if !found { if !found {
slog.Error("No PS2 ID found in the file", "file", file)
return "", fmt.Errorf("no PS2 game ID found in file") return "", fmt.Errorf("no PS2 game ID found in file")
} }
@@ -53,9 +57,9 @@ func normalizeGameID(id string) string {
} }
prefix := strings.ToUpper(matches[1]) prefix := strings.ToUpper(matches[1])
number := matches[2] + matches[3] number := matches[2] + "." + matches[3]
return prefix + "-" + number return prefix + "_" + number
} }
func renameGameFile(filePath, gameName string) (string, error) { func renameGameFile(filePath, gameName string) (string, error) {

View File

@@ -12,37 +12,37 @@ func TestParseCases(t *testing.T) {
{ {
name: "SLUS with underscore and dot", name: "SLUS with underscore and dot",
content: []byte("Some binary data\x00\x00SLUS_123.45\x00\x00more data"), content: []byte("Some binary data\x00\x00SLUS_123.45\x00\x00more data"),
expected: "SLUS-12345", expected: "SLUS_123.45",
}, },
{ {
name: "SLUS with hyphen", name: "SLUS with hyphen",
content: []byte("\x00\x00\x00SLUS-12345\x00\x00\x00"), content: []byte("\x00\x00\x00SLUS-12345\x00\x00\x00"),
expected: "SLUS-12345", expected: "SLUS_123.45",
}, },
{ {
name: "SLES format", name: "SLES format",
content: []byte("Header data\x00SLES_987.65\x00footer"), content: []byte("Header data\x00SLES_987.65\x00footer"),
expected: "SLES-98765", expected: "SLES_987.65",
}, },
{ {
name: "SLPS format", name: "SLPS format",
content: []byte("\xFF\xFESLPS_456.78\x00"), content: []byte("\xFF\xFESLPS_456.78\x00"),
expected: "SLPS-45678", expected: "SLPS_456.78",
}, },
{ {
name: "SCUS format", name: "SCUS format",
content: []byte("SCUS-97512"), content: []byte("SCUS-97512"),
expected: "SCUS-97512", expected: "SCUS_975.12",
}, },
{ {
name: "No separator format", name: "No separator format",
content: []byte("\x00\x00SLUS12345\x00\x00"), content: []byte("\x00\x00SLUS12345\x00\x00"),
expected: "SLUS-12345", expected: "SLUS_123.45",
}, },
{ {
name: "With spaces", name: "With spaces",
content: []byte("SLUS 123.45"), content: []byte("SLUS 123.45"),
expected: "SLUS-12345", expected: "SLUS_123.45",
}, },
{ {
name: "No game ID found", name: "No game ID found",
@@ -82,31 +82,31 @@ func TestNormalize(t *testing.T) {
}{ }{
{ {
input: "SLUS-12345", input: "SLUS-12345",
expected: "SLUS-12345", expected: "SLUS_123.45",
}, },
{ {
input: "SLES_987.65", input: "SLES_987.65",
expected: "SLES-98765", expected: "SLES_987.65",
}, },
{ {
input: "SLPS_456.78", input: "SLPS_456.78",
expected: "SLPS-45678", expected: "SLPS_456.78",
}, },
{ {
input: "scus-97512", input: "scus-97512",
expected: "SCUS-97512", expected: "SCUS_975.12",
}, },
{ {
input: "SLUS12345", input: "SLUS12345",
expected: "SLUS-12345", expected: "SLUS_123.45",
}, },
{ {
input: "SLUS123.45", input: "SLUS123.45",
expected: "SLUS-12345", expected: "SLUS_123.45",
}, },
{ {
input: "SLUs123.45", input: "SLUs123.45",
expected: "SLUS-12345", expected: "SLUS_123.45",
}, },
} }

View File

@@ -3,6 +3,7 @@ package oplcli
import ( import (
"bufio" "bufio"
"fmt" "fmt"
"log/slog"
"os" "os"
"strings" "strings"
) )
@@ -16,15 +17,16 @@ func loadGamelist(filepath string) (map[string]string, error) {
gameList := make(map[string]string) gameList := make(map[string]string)
lineNum := 0
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(file)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
lineNum++
substrings := strings.SplitN(line, " ", 2) substrings := strings.SplitN(line, " ", 2)
if len(substrings) != 2 { if len(substrings) != 2 {
// TODO: something in the gamelist file is not formatted right slog.Warn("Line in gamelist is malformed", "line", lineNum)
fmt.Printf("Malformed: %s\n", line)
continue continue
} }

View File

@@ -1,59 +1,95 @@
package oplcli package oplcli
import ( import (
"fmt" "log/slog"
"os" "os"
) )
type Config struct { type Config struct {
GameListLocation string
} }
func Scan(file string, config Config) { func Scan(files []string, config Config) {
fmt.Printf("Scanning file: %s\n", file) gameList, err := loadGamelist(config.GameListLocation)
if err != nil {
slog.Error("Failed to load game list", "file", config.GameListLocation)
os.Exit(1)
}
for _, file := range files {
slog.Info("Scanning file", "file", file)
gameID, err := scanGameFileForID(file)
if err != nil {
slog.Error("Failed to scan game file", "file", file)
os.Exit(1)
}
slog.Info("Found game ID", "id", gameID)
gameName := gameList[gameID]
slog.Info("Found game name", "name", gameName)
}
}
func Rename(files []string, config Config) {
gameList, err := loadGamelist(config.GameListLocation)
if err != nil {
slog.Error("Failed to load game list", "file", config.GameListLocation)
os.Exit(1)
}
for _, file := range files {
slog.Info("Scanning file", "file", file)
gameID, err := scanGameFileForID(file) gameID, err := scanGameFileForID(file)
if err != nil { if err != nil {
fmt.Printf("Failed to scan game file: %s", err) slog.Error("Failed to scan game file", "file", file)
os.Exit(1) os.Exit(1)
} }
fmt.Printf("Found game ID: %s\n", gameID) slog.Info("Found game ID", "id", gameID)
gameList, err := loadGamelist("./ps2-gameslist.txt")
if err != nil {
fmt.Printf("Failed to load game list: %v", err)
os.Exit(1)
}
gameName := gameList[gameID] gameName := gameList[gameID]
fmt.Printf("Found game name: %s\n", gameName) slog.Info("Found game name", "name", gameName)
}
func Rename(file string, config Config) {
fmt.Printf("Scanning file: %s\n", file)
gameID, err := scanGameFileForID(file)
if err != nil {
fmt.Printf("Failed to scan game file: %s", err)
os.Exit(1)
}
fmt.Printf("Found game ID: %s\n", gameID)
gameList, err := loadGamelist("./ps2-gameslist.txt")
if err != nil {
fmt.Printf("Failed to load game list: %v", err)
os.Exit(1)
}
gameName := gameList[gameID]
fmt.Printf("Found game name: %s\n", gameName)
newPath, err := renameGameFile(file, gameName) newPath, err := renameGameFile(file, gameName)
if err != nil { if err != nil {
fmt.Printf("Failed to rename file: %v", err) slog.Error("Failed to reanme file", "file", file, "gameName", gameName, "err", err)
os.Exit(1) os.Exit(1)
} }
fmt.Printf("Renamed %s to %s", file, newPath) slog.Info("Renamed file", "file", file, "to", newPath)
}
}
func DownloadGameArt(files []string, output string, config Config) {
for _, file := range files {
slog.Info("Scanning file", "file", file)
gameID, err := scanGameFileForID(file)
if err != nil {
slog.Error("Failed to scan game file", "file", file)
os.Exit(1)
}
slog.Info("Found game ID", "id", gameID)
downloadArtForGame(gameID, output)
}
}
func DownloadCfg(files []string, output string, mode FileMode, config Config) {
for _, file := range files {
slog.Info("Scanning file", "file", file)
gameID, err := scanGameFileForID(file)
if err != nil {
slog.Error("Failed to scan game file", "file", file)
os.Exit(1)
}
slog.Info("Found game ID", "id", gameID)
downloadCfgForGame(gameID, output, mode)
}
} }

File diff suppressed because it is too large Load Diff