From dd01f3b3b619d62d473705119e52f538c0306293 Mon Sep 17 00:00:00 2001 From: Niklas Kapelle Date: Wed, 29 Oct 2025 01:34:11 +0100 Subject: [PATCH] initial commit --- cmd/steamimmich.go | 25 +++++++++ go.mod | 8 +++ go.sum | 7 +++ internal/immich.go | 113 ++++++++++++++++++++++++++++++++++++++++ internal/screenshots.go | 52 ++++++++++++++++++ internal/steam.go | 55 +++++++++++++++++++ internal/steamimmich.go | 55 +++++++++++++++++++ 7 files changed, 315 insertions(+) create mode 100644 cmd/steamimmich.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/immich.go create mode 100644 internal/screenshots.go create mode 100644 internal/steam.go create mode 100644 internal/steamimmich.go diff --git a/cmd/steamimmich.go b/cmd/steamimmich.go new file mode 100644 index 0000000..86acaf2 --- /dev/null +++ b/cmd/steamimmich.go @@ -0,0 +1,25 @@ +package main + +import ( + "os" + + steamimmich "git.kapelle.org/niklas/steam-immich/internal" + "github.com/alexflint/go-arg" +) + +type args struct { + BaseURL string `arg:"--base-url,required,env:BASE_URL" placeholder:"https://demo.immich.app"` + APIKey string `arg:"--api-key,required,env:API_KEY" placeholder:"API_KEY"` + UserdataDir string `arg:"--steam-userdata-dir,env:USERDATA_DIR" default:"$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/userdata"` +} + +func main() { + var args args + arg.MustParse(&args) + + steamimmich.Run(steamimmich.Config{ + APIKey: args.APIKey, + BaseURL: args.BaseURL, + UserdataDir: os.ExpandEnv(args.UserdataDir), + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f343de5 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module git.kapelle.org/niklas/steam-immich + +go 1.24.6 + +require ( + github.com/alexflint/go-arg v1.6.0 // indirect + github.com/alexflint/go-scalar v1.2.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3efdc52 --- /dev/null +++ b/go.sum @@ -0,0 +1,7 @@ +github.com/alexflint/go-arg v1.6.0 h1:wPP9TwTPO54fUVQl4nZoxbFfKCcy5E6HBCumj1XVRSo= +github.com/alexflint/go-arg v1.6.0/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8= +github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= +github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= diff --git a/internal/immich.go b/internal/immich.go new file mode 100644 index 0000000..3c42f17 --- /dev/null +++ b/internal/immich.go @@ -0,0 +1,113 @@ +package steamimmich + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "time" +) + +type ImmichAPIUploadResponse struct { + ID string `json:"id"` + Status string `json:"status"` +} + +type ImmichAPIAssetPutRequest struct { + Ids []string `json:"ids"` + Description string `json:"description,omitempty"` +} + +func uploadToImmich(filePath, appID, baseURL, apiKey, deviceID string) (*ImmichAPIUploadResponse, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("open file: %w", err) + } + defer file.Close() + + fileInfo, err := file.Stat() + if err != nil { + return nil, fmt.Errorf("stat file: %w", err) + } + + // Create multipart form + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + // Attach the image file + part, err := writer.CreateFormFile("assetData", filepath.Base(filePath)) + if err != nil { + return nil, fmt.Errorf("create form file: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + return nil, fmt.Errorf("copy file data: %w", err) + } + + modTime := fileInfo.ModTime().UTC().Format(time.RFC3339) + createTime := modTime // If you don't have a separate created time + + // Required metadata fields + writer.WriteField("deviceAssetId", fmt.Sprintf("%s-%s", appID, filepath.Base(filePath))) + writer.WriteField("deviceId", deviceID) + writer.WriteField("fileCreatedAt", createTime) + writer.WriteField("fileModifiedAt", modTime) + writer.WriteField("isFavorite", "false") + + writer.Close() + + // Build request + req, err := http.NewRequest("POST", fmt.Sprintf("%s/api/assets", baseURL), &body) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("x-api-key", apiKey) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("POST to Immich: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("Immich upload failed (%d): %s", resp.StatusCode, string(respBody)) + } + + var result ImmichAPIUploadResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("Failed to parse immich response: %s", err) + } + + return &result, nil +} + +func setAssetMetadata(assets []string, description, baseURL, apiKey string) error { + body, _ := json.Marshal(ImmichAPIAssetPutRequest{ + Ids: assets, + Description: description, + }) + + req, err := http.NewRequest("PUT", fmt.Sprintf("%s/api/assets", baseURL), bytes.NewBuffer(body)) + if err != nil { + return err + } + + req.Header.Set("x-api-key", apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + _, err = http.DefaultClient.Do(req) + if err != nil { + return err + } + + return nil +} + diff --git a/internal/screenshots.go b/internal/screenshots.go new file mode 100644 index 0000000..7a84e7b --- /dev/null +++ b/internal/screenshots.go @@ -0,0 +1,52 @@ +package steamimmich + +import ( + "os" + "path/filepath" + "regexp" + "strings" +) + +func listScreenshots(steamUserdataDir string) (map[string][]string, error) { + if _, err := os.Stat(steamUserdataDir); os.IsNotExist(err) { + return nil, err + } + + // Pattern: userdata//760/remote//screenshots + reAppDir := regexp.MustCompile(`.*/760/remote/([0-9]+)/screenshots$`) + + screenshotFiles := make(map[string][]string) + + err := filepath.WalkDir(steamUserdataDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if !d.IsDir() { + return nil + } + + if reAppDir.MatchString(path) { + appid := reAppDir.FindStringSubmatch(path)[1] + files, _ := os.ReadDir(path) + for _, f := range files { + if f.IsDir() { + continue + } + name := strings.ToLower(f.Name()) + if strings.HasSuffix(name, ".jpg") || + strings.HasSuffix(name, ".jpeg") || + strings.HasSuffix(name, ".png") { + fullPath := filepath.Join(path, f.Name()) + screenshotFiles[appid] = append(screenshotFiles[appid], fullPath) + } + } + } + return nil + }) + + if err != nil { + return nil, err + } + + return screenshotFiles, nil +} diff --git a/internal/steam.go b/internal/steam.go new file mode 100644 index 0000000..7e5435d --- /dev/null +++ b/internal/steam.go @@ -0,0 +1,55 @@ +package steamimmich + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +const UNKOWN_GAME_GAME = "Unkown" + +type SteamAPIResponse struct { + Success bool `json:"success"` + Data struct { + Name string `json:"name"` + } `json:"data"` +} + +func getGameName(appid string, cache map[string]string) string { + if name, ok := cache[appid]; ok { + return name + } + + url := fmt.Sprintf("https://store.steampowered.com/api/appdetails?appids=%s", appid) + resp, err := http.Get(url) + if err != nil { + fmt.Printf("Error fetching app name for %s: %v\n", appid, err) + cache[appid] = UNKOWN_GAME_GAME + return cache[appid] + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Printf("Error reading response for %s: %v\n", appid, err) + cache[appid] = UNKOWN_GAME_GAME + return cache[appid] + } + + var result map[string]SteamAPIResponse + if err := json.Unmarshal(body, &result); err != nil { + fmt.Printf("Error parsing JSON for %s: %v\n", appid, err) + cache[appid] = UNKOWN_GAME_GAME + return cache[appid] + } + + if entry, ok := result[appid]; ok && entry.Success { + name := entry.Data.Name + cache[appid] = name + return name + } + + cache[appid] = UNKOWN_GAME_GAME + return cache[appid] +} diff --git a/internal/steamimmich.go b/internal/steamimmich.go new file mode 100644 index 0000000..eb329b2 --- /dev/null +++ b/internal/steamimmich.go @@ -0,0 +1,55 @@ +package steamimmich + +import ( + "fmt" + "os" +) + +type Config struct { + BaseURL string + APIKey string + UserdataDir string +} + +func Run(config Config) { + screenshotFiles, err := listScreenshots(config.UserdataDir) + + if err != nil { + fmt.Println("Failed to scan for screenshots") + os.Exit(1) + } + + if len(screenshotFiles) == 0 { + fmt.Println("No screenshots found.") + return + } + + gameNameCache := make(map[string]string) + + for appid, files := range screenshotFiles { + gameName := getGameName(appid, gameNameCache) + fmt.Printf("AppID: %s → %s\n", appid, gameName) + + assetsInGame := make([]string, 0) + + for _, f := range files { + fmt.Printf(" %s\n", f) + + res, err := uploadToImmich(f, appid, config.BaseURL, config.APIKey, "steam-screenshot-test") + + if err != nil { + fmt.Printf("Failed to upload to immich: %s", err) + return + } + + fmt.Printf("Done. ID: %s", res.ID) + + assetsInGame = append(assetsInGame, res.ID) + } + + setAssetMetadata(assetsInGame, fmt.Sprintf("Game: %s", gameName), config.BaseURL, config.APIKey) + + fmt.Println() + } + +}