Created
September 16, 2024 09:21
-
-
Save ku/fa154a411422d156e9b9ce55bb0b091b to your computer and use it in GitHub Desktop.
flickr -> google photos uploader without dupes
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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