initial commit
This commit is contained in:
		
						commit
						dd01f3b3b6
					
				
							
								
								
									
										25
									
								
								cmd/steamimmich.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								cmd/steamimmich.go
									
									
									
									
									
										Normal file
									
								
							@ -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),
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										8
									
								
								go.mod
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								go.mod
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
							
								
								
									
										7
									
								
								go.sum
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								go.sum
									
									
									
									
									
										Normal file
									
								
							@ -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=
 | 
				
			||||||
							
								
								
									
										113
									
								
								internal/immich.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								internal/immich.go
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										52
									
								
								internal/screenshots.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								internal/screenshots.go
									
									
									
									
									
										Normal file
									
								
							@ -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/<steamid>/760/remote/<appid>/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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										55
									
								
								internal/steam.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								internal/steam.go
									
									
									
									
									
										Normal file
									
								
							@ -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]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										55
									
								
								internal/steamimmich.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								internal/steamimmich.go
									
									
									
									
									
										Normal file
									
								
							@ -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()
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user