Skip to content

Instantly share code, notes, and snippets.

@ku
Created September 16, 2024 09:21
Show Gist options
  • Save ku/fa154a411422d156e9b9ce55bb0b091b to your computer and use it in GitHub Desktop.
Save ku/fa154a411422d156e9b9ce55bb0b091b to your computer and use it in GitHub Desktop.
flickr -> google photos uploader without dupes
package main
// CREATE TABLE images ( id BLOB PRIMARY KEY, filename TEXT, source TEXT);
// https://max-coding.medium.com/loading-photos-and-metadata-using-google-photos-api-with-python-7fb5bd8886ef
import (
"context"
"crypto/md5"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
gphotos "github.com/gphotosuploader/google-photos-api-client-go/v3"
sqlite3 "github.com/mattn/go-sqlite3" // Import the SQLite3 driver
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
type GooglePhotosUploader struct {
client *gphotos.Client
}
func NewGooglePhotosUploader(httpClient *http.Client) (*GooglePhotosUploader, error) {
// Create the Google Photos client
photosClient, err := gphotos.NewClient(httpClient)
if err != nil {
return nil, fmt.Errorf("failed to create google photos client: %w", err)
}
return &GooglePhotosUploader{
client: photosClient,
}, nil
}
func (g *GooglePhotosUploader) get(ctx context.Context, photoID string) error {
m, err := g.client.MediaItems.Get(ctx, photoID)
if err != nil {
return fmt.Errorf("failed to get media item: %w", err)
}
println(m.ProductURL)
return nil
}
func (g *GooglePhotosUploader) upload(ctx context.Context, filePath string) error {
m, err := g.client.Upload(ctx, filePath)
if err != nil {
return fmt.Errorf("failed to upload file to google photos: %w", err)
}
println("successfully", filePath, m.Filename, m.ID)
return nil
}
func main() {
if len(os.Args) != 3 {
println("Usage: " + os.Args[0] + " <index|upload> <dir>")
os.Exit(1)
}
mode := os.Args[1]
if err := _main(mode, os.Args[2:]); err != nil {
fmt.Println("Error:", err)
os.Exit(1)
}
}
func openSqlite3Database(filename string) (*sql.DB, func() error, error) {
db, err := sql.Open("sqlite3", filename)
if err != nil {
return nil, nil, fmt.Errorf("failed to open db: %w", err)
}
return db, db.Close, nil
}
func traverseDir(dir string, f func(string) error) error {
ents, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, ent := range ents {
if ent.IsDir() {
if err := traverseDir(dir+"/"+ent.Name(), f); err != nil {
return err
}
} else {
if err := f(dir + "/" + ent.Name()); err != nil {
return err
}
}
}
return nil
}
func _main(mode string, args []string) error {
db, cleanup, err := openSqlite3Database("./imagedb.sqlite3")
if err != nil {
return err
}
defer cleanup()
ctx := context.Background()
httpClient, err := authenticatedHTTPClient(ctx)
if err != nil {
return fmt.Errorf("failed to create httpClient: %w", err)
}
g, err := NewGooglePhotosUploader(httpClient)
if err != nil {
return fmt.Errorf("failed to create google photos uploader: %w", err)
}
switch mode {
case "get":
photoID := args[0]
return g.get(ctx, photoID)
case "index":
dir := args[0]
return traverseDir(dir, func(filePath string) error {
if strings.HasSuffix(filePath, ".json") {
return nil
}
md5sum, err := md5HashOfFile(filePath)
if err != nil {
return fmt.Errorf("failed to calculate md5 hash of file: %w", err)
}
stmt, err := db.Prepare("INSERT INTO images (id, filename) VALUES (?, ?)")
if err != nil {
return fmt.Errorf("failed to prepare insert: %w", err)
}
defer stmt.Close()
_, err = stmt.Exec(md5sum, filePath)
if err != nil {
if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.Code == sqlite3.ErrConstraint {
println("dupe", filePath)
// Ignore unique constraint violation
return nil
}
return fmt.Errorf("failed to insert: %w", err)
}
return nil
})
case "upload":
dir := args[0]
return traverseDir(dir, func(filePath string) error {
md5sum, err := md5HashOfFile(filePath)
if err != nil {
return err
}
if mode == "index" {
stmt, err := db.Prepare("INSERT INTO images (id, filename) VALUES (?, ?)")
if err != nil {
return fmt.Errorf("failed to prepare insert: %w", err)
}
defer stmt.Close()
_, err = stmt.Exec(md5sum, filePath)
if err != nil {
return fmt.Errorf("failed to insert: %w", err)
}
println("indexed:", filePath, md5sum)
} else if mode == "upload" {
// test if it's already in the db
stmt, err := db.Prepare("SELECT id FROM images WHERE id = ?")
if err != nil {
return fmt.Errorf("failed to prepare select: %w", err)
}
defer stmt.Close()
var b []byte
err = stmt.QueryRow(md5sum).Scan(&b)
if err == nil {
println("already uploaded:", filePath, md5sum)
return nil
} else {
if err != sql.ErrNoRows {
return fmt.Errorf("failed to query: %w", err)
}
}
if err := g.upload(ctx, filePath); err != nil {
return fmt.Errorf("failed to upload: %w", err)
}
// Insert new record if upload was successful
stmt, err = db.Prepare(`INSERT INTO images (id, filename, source) VALUES (?, ?, 'flickr')`)
if err != nil {
return fmt.Errorf("failed to prepare insert: %w", err)
}
defer stmt.Close()
_, err = stmt.Exec(md5sum, filePath)
if err != nil {
return fmt.Errorf("failed to insert after upload: %w", err)
}
println("uploaded and indexed:", filePath, md5sum)
return nil
}
return nil
})
default:
return fmt.Errorf("unknown mode: %s", mode)
}
}
func authenticatedHTTPClient(ctx context.Context) (*http.Client, error) {
// Check if token file exists
tokenFile := "_secrets_/token.json"
token := &oauth2.Token{}
if _, err := os.Stat(tokenFile); err == nil {
// Read the token file
data, err := os.ReadFile(tokenFile)
if err != nil {
return nil, fmt.Errorf("unable to read token file: %w", err)
}
if err := json.Unmarshal(data, token); err != nil {
return nil, fmt.Errorf("unable to parse token file: %w", err)
}
}
// Read the client secret file
b, err := os.ReadFile("secret.json")
if err != nil {
return nil, fmt.Errorf("unable to read client secret file: %w", err)
}
// Configure the OAuth2 config
config, err := google.ConfigFromJSON(b, gphotos.PhotoslibraryScope)
if err != nil {
return nil, fmt.Errorf("unable to parse client secret file to config: %w", err)
}
// Create a token source
tokenSource := config.TokenSource(ctx, token)
// Check if token is valid or needs refresh
if !token.Valid() {
if token.RefreshToken != "" {
// Refresh the token
newToken, err := tokenSource.Token()
if err != nil {
return nil, fmt.Errorf("unable to refresh token: %w", err)
}
token = newToken
} else {
// Get a new token
authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
fmt.Printf("Go to the following link in your browser then type the "+
"authorization code: \n%v\n", authURL)
var authCode string
if _, err := fmt.Scan(&authCode); err != nil {
return nil, fmt.Errorf("unable to read authorization code: %w", err)
}
newToken, err := config.Exchange(ctx, authCode)
if err != nil {
return nil, fmt.Errorf("unable to retrieve token from web: %w", err)
}
token = newToken
println("new token", token)
}
// Save the token
data, err := json.Marshal(token)
if err != nil {
return nil, fmt.Errorf("unable to marshal token: %w", err)
}
if err := os.WriteFile(tokenFile, data, 0600); err != nil {
return nil, fmt.Errorf("unable to cache oauth token: %w", err)
}
}
// Create an HTTP client using the token
return oauth2.NewClient(ctx, tokenSource), nil
}
func md5HashOfFile(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("error opening file: %w", err)
}
defer file.Close()
hash := md5.New()
if _, err := io.Copy(hash, file); err != nil {
return "", fmt.Errorf("error calculating hash: %w", err)
}
md5sum := fmt.Sprintf("%x", hash.Sum(nil))
return md5sum, nil
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment