DEV Community

kazi983
kazi983

Posted on

Automating Ubuntu Screenshots to Google Photos

Automatically Upload Ubuntu Screenshots to Google Photos

This setup automatically uploads screenshots from Ubuntu to Google Photos.

The workflow is:

Screenshot
    ↓
~/Pictures/GooglePhotos/upload/
    ↓
inotify detects the new PNG
    ↓
Read the file's Birth timestamp
    ↓
Embed DateTimeOriginal + timezone into PNG
    ↓
Upload to Google Photos
    ↓
Move the screenshot to ~/Pictures/GooglePhotos/uploaded/
Enter fullscreen mode Exit fullscreen mode

The original screenshot is never modified.


1. Requirements

  • Ubuntu
  • Python 3
  • A Google account
  • A Google Cloud project
  • Google Photos Library API enabled
  • OAuth 2.0 Desktop application credentials

2. Create the project

Create a directory for the uploader:

mkdir -p ~/bin/google-photos-uploader
cd ~/bin/google-photos-uploader
Enter fullscreen mode Exit fullscreen mode

Create a Python virtual environment:

python3 -m venv .venv
source .venv/bin/activate
Enter fullscreen mode Exit fullscreen mode

Install the required Python packages:

pip install google-auth-oauthlib
pip install google-auth-httplib2
pip install google-api-python-client
pip install requests
pip install Pillow
Enter fullscreen mode Exit fullscreen mode

3. Google Cloud OAuth setup

Create an OAuth 2.0 Client ID in Google Cloud.

The application type should be:

Desktop app
Enter fullscreen mode Exit fullscreen mode

Store the downloaded client secret outside the project:

mkdir -p ~/.config/google-photos
Enter fullscreen mode Exit fullscreen mode

For example:

~/.config/google-photos/client_secret.json
Enter fullscreen mode Exit fullscreen mode

Do not put this file into Git.


4. Google Photos API scope

The uploader uses:

https://www.googleapis.com/auth/photoslibrary.appendonly
Enter fullscreen mode Exit fullscreen mode

This allows the application to add new media to Google Photos.

Google Photos uses a two-step upload process:

Image bytes
    ↓
POST /v1/uploads
    ↓
Upload token
    ↓
POST /v1/mediaItems:batchCreate
    ↓
Google Photos media item
Enter fullscreen mode Exit fullscreen mode

5. Authenticate with Google

Create:

~/bin/google-photos-uploader/auth.py
Enter fullscreen mode Exit fullscreen mode
from pathlib import Path

from google_auth_oauthlib.flow import InstalledAppFlow


CLIENT_SECRET_FILE = (
    Path.home()
    / ".config/google-photos/client_secret.json"
)

TOKEN_FILE = (
    Path.home()
    / ".config/google-photos/token.json"
)

SCOPES = [
    "https://www.googleapis.com/auth/photoslibrary.appendonly"
]


def main():
    flow = InstalledAppFlow.from_client_secrets_file(
        CLIENT_SECRET_FILE,
        SCOPES,
    )

    credentials = flow.run_local_server(
        port=0,
        access_type="offline",
        prompt="consent",
    )

    TOKEN_FILE.parent.mkdir(
        parents=True,
        exist_ok=True,
    )

    TOKEN_FILE.write_text(
        credentials.to_json()
    )

    TOKEN_FILE.chmod(0o600)

    print(f"Token saved to: {TOKEN_FILE}")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Run:

python auth.py
Enter fullscreen mode Exit fullscreen mode

Complete the Google login in the browser.

The OAuth token will be saved to:

~/.config/google-photos/token.json
Enter fullscreen mode Exit fullscreen mode

Protect it:

chmod 600 ~/.config/google-photos/token.json
Enter fullscreen mode Exit fullscreen mode

The token must not be committed to Git.


6. Determine the screenshot's capture time

A major problem is that Google Photos does not automatically know when an Ubuntu screenshot was taken.

Simply uploading the file resulted in Google Photos using the upload time.

The filesystem provides a useful timestamp:

stat ~/Pictures/GooglePhotos/upload/test.png
Enter fullscreen mode Exit fullscreen mode

Example:

Access: 2026-09-14 21:59:08.960924155 -0700
Modify: 2026-09-14 21:57:24.320354076 -0700
Change: 2026-09-14 21:59:05.603970029 -0700
Birth:  2026-09-14 21:57:24.313354172 -0700
Enter fullscreen mode Exit fullscreen mode

The important value is:

Birth
Enter fullscreen mode Exit fullscreen mode

For screenshots, this corresponds to when the screenshot file was created.

Python's os.stat() does not expose st_birthtime on this Ubuntu system, so GNU stat is used:

def get_birth_time(file_path: Path) -> datetime:
    result = subprocess.run(
        [
            "stat",
            "-c",
            "%w",
            str(file_path),
        ],
        capture_output=True,
        text=True,
        check=True,
    )

    birth_time = result.stdout.strip()

    if birth_time == "-":
        raise RuntimeError(
            f"Birth time is not available: {file_path}"
        )

    return datetime.fromisoformat(birth_time)
Enter fullscreen mode Exit fullscreen mode

7. Preserve the correct timezone

There is an important timezone issue.

Suppose a screenshot is taken in Vancouver at:

2026-09-14 22:00
Enter fullscreen mode Exit fullscreen mode

Vancouver is UTC-7 during daylight saving time.

That same moment is:

2026-09-15 05:00 UTC
Enter fullscreen mode Exit fullscreen mode

Google Photos/API may represent the timestamp as:

2026-09-15T05:00:00Z
Enter fullscreen mode Exit fullscreen mode

That is correct.

The problem occurs when the local 22:00 is treated as if it were UTC:

22:00 UTC
Enter fullscreen mode Exit fullscreen mode

which would display as:

15:00 Vancouver
Enter fullscreen mode Exit fullscreen mode

To avoid this, the timezone offset must be included in the image metadata.


8. Embed EXIF metadata into the PNG

Google Photos successfully recognized the PNG's EXIF metadata when the following fields were set:

DateTime
DateTimeOriginal
DateTimeDigitized
OffsetTimeOriginal
OffsetTimeDigitized
Enter fullscreen mode Exit fullscreen mode

The important field is:

DateTimeOriginal
Enter fullscreen mode Exit fullscreen mode

with the corresponding timezone offset.

Pillow can write this metadata directly into the PNG, so exiftool is not required.


9. upload.py

The final uploader accepts an image path as a command-line argument.

Create:

~/bin/google-photos-uploader/upload.py
Enter fullscreen mode Exit fullscreen mode
from datetime import datetime
from pathlib import Path
import subprocess
import sys
import tempfile

import requests
from PIL import Image
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials


TOKEN_FILE = (
    Path.home()
    / ".config"
    / "google-photos"
    / "token.json"
)

UPLOAD_URL = "https://photoslibrary.googleapis.com/v1/uploads"

BATCH_CREATE_URL = (
    "https://photoslibrary.googleapis.com/v1/mediaItems:batchCreate"
)

SCOPE = (
    "https://www.googleapis.com/auth/photoslibrary.appendonly"
)


def get_credentials():
    credentials = Credentials.from_authorized_user_file(
        TOKEN_FILE,
        scopes=[SCOPE],
    )

    if credentials.expired and credentials.refresh_token:
        credentials.refresh(Request())

        TOKEN_FILE.write_text(
            credentials.to_json()
        )

        TOKEN_FILE.chmod(0o600)

    return credentials


def get_birth_time(file_path: Path) -> datetime:
    result = subprocess.run(
        [
            "stat",
            "-c",
            "%w",
            str(file_path),
        ],
        capture_output=True,
        text=True,
        check=True,
    )

    birth_time = result.stdout.strip()

    if birth_time == "-":
        raise RuntimeError(
            f"Birth time is not available: {file_path}"
        )

    return datetime.fromisoformat(birth_time)


def add_exif_creation_time(
    source: Path,
    destination: Path,
    creation_time: datetime,
):
    with Image.open(source) as image:
        exif = image.getexif()

        # EXIF DateTime
        exif[306] = creation_time.strftime(
            "%Y:%m:%d %H:%M:%S"
        )

        # EXIF DateTimeOriginal
        exif[36867] = creation_time.strftime(
            "%Y:%m:%d %H:%M:%S"
        )

        # EXIF DateTimeDigitized
        exif[36868] = creation_time.strftime(
            "%Y:%m:%d %H:%M:%S"
        )

        # EXIF timezone offset
        if creation_time.utcoffset() is not None:
            offset = creation_time.strftime("%z")
            offset = f"{offset[:3]}:{offset[3:]}"

            exif[36881] = offset
            exif[36882] = offset

        image.save(
            destination,
            format="PNG",
            exif=exif.tobytes(),
        )


def upload_file(
    file_path: Path,
    credentials: Credentials,
):
    headers = {
        "Authorization": f"Bearer {credentials.token}",
        "Content-Type": "application/octet-stream",
        "X-Goog-Upload-Content-Type": "image/png",
        "X-Goog-Upload-File-Name": file_path.name,
        "X-Goog-Upload-Protocol": "raw",
    }

    with file_path.open("rb") as file:
        response = requests.post(
            UPLOAD_URL,
            headers=headers,
            data=file,
        )

    response.raise_for_status()

    return response.text


def create_media_item(
    file_path: Path,
    upload_token: str,
    credentials: Credentials,
):
    headers = {
        "Authorization": f"Bearer {credentials.token}",
        "Content-Type": "application/json",
    }

    body = {
        "newMediaItems": [
            {
                "simpleMediaItem": {
                    "fileName": file_path.name,
                    "uploadToken": upload_token,
                }
            }
        ]
    }

    response = requests.post(
        BATCH_CREATE_URL,
        headers=headers,
        json=body,
    )

    response.raise_for_status()

    return response.json()


def main():
    if len(sys.argv) != 2:
        raise SystemExit(
            f"Usage: {sys.argv[0]} <image>"
        )

    image_file = Path(
        sys.argv[1]
    ).expanduser()

    if not image_file.is_file():
        raise FileNotFoundError(image_file)

    birth_time = get_birth_time(image_file)

    print(f"Original file: {image_file}")
    print(f"Birth time:    {birth_time.isoformat()}")

    credentials = get_credentials()

    with tempfile.TemporaryDirectory() as temp_dir:
        temp_file = Path(temp_dir) / image_file.name

        print(
            "Embedding EXIF DateTimeOriginal..."
        )

        add_exif_creation_time(
            image_file,
            temp_file,
            birth_time,
        )

        print(f"Uploading:     {temp_file}")

        upload_token = upload_file(
            temp_file,
            credentials,
        )

        result = create_media_item(
            temp_file,
            upload_token,
            credentials,
        )

    print("\nUpload successful.")
    print(result)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

10. Test the uploader

The uploader can now accept any PNG:

cd ~/bin/google-photos-uploader

./.venv/bin/python \
    upload.py \
    ~/Pictures/GooglePhotos/upload/test.png
Enter fullscreen mode Exit fullscreen mode

The original PNG is not modified.

A temporary copy is created, metadata is added, and the temporary file is uploaded.


11. Create the upload folders

Create two directories:

mkdir -p ~/Pictures/GooglePhotos/upload
mkdir -p ~/Pictures/GooglePhotos/uploaded
Enter fullscreen mode Exit fullscreen mode

The roles are:

upload/
    screenshots waiting to be uploaded

uploaded/
    screenshots that were successfully uploaded
Enter fullscreen mode Exit fullscreen mode

Keeping successfully uploaded files locally provides an additional backup and prevents accidentally uploading the same file again.


12. Install inotify-tools

Ubuntu provides Linux filesystem notifications through inotify.

Install the command-line tools:

sudo apt install inotify-tools
Enter fullscreen mode Exit fullscreen mode

inotifywait can monitor the screenshot directory without adding another Python dependency.


13. Test inotify

Run:

inotifywait -m \
    -e close_write,moved_to \
    ~/Pictures/GooglePhotos/upload/
Enter fullscreen mode Exit fullscreen mode

You should see:

Setting up watches.
Watches established.
Enter fullscreen mode Exit fullscreen mode

Create a test file from another terminal:

touch ~/Pictures/GooglePhotos/upload/test-watch.png
Enter fullscreen mode Exit fullscreen mode

The watcher should report:

/home/YOUR_USERNAME/Pictures/GooglePhotos/upload/ CLOSE_WRITE,CLOSE test-watch.png
Enter fullscreen mode Exit fullscreen mode

The close_write event is useful because it indicates that the application has finished writing the file.


14. Create watch.sh

Create:

~/bin/google-photos-uploader/watch.sh
Enter fullscreen mode Exit fullscreen mode
#!/bin/bash

set -euo pipefail

WATCH_DIR="$HOME/Pictures/GooglePhotos/upload"
UPLOADED_DIR="$HOME/Pictures/GooglePhotos/uploaded"
PROJECT_DIR="$HOME/bin/google-photos-uploader"

mkdir -p "$WATCH_DIR"
mkdir -p "$UPLOADED_DIR"

inotifywait -m \
    -e close_write,moved_to \
    --format '%w%f' \
    "$WATCH_DIR" |
while IFS= read -r file; do

    case "$file" in
        *.png|*.PNG)
            echo "New screenshot: $file"

            if "$PROJECT_DIR/.venv/bin/python" \
                "$PROJECT_DIR/upload.py" \
                "$file"; then

                mv -- "$file" "$UPLOADED_DIR/"
                echo "Uploaded: $file"
            else
                echo "Upload failed: $file" >&2
            fi
            ;;
    esac

done
Enter fullscreen mode Exit fullscreen mode

Make it executable:

chmod +x ~/bin/google-photos-uploader/watch.sh
Enter fullscreen mode Exit fullscreen mode

15. Test the complete pipeline

Start the watcher:

~/bin/google-photos-uploader/watch.sh
Enter fullscreen mode Exit fullscreen mode

Leave it running.

Take a screenshot and save it into:

~/Pictures/GooglePhotos/upload/
Enter fullscreen mode Exit fullscreen mode

The watcher should:

  1. Detect the PNG.
  2. Read its Birth timestamp.
  3. Create a temporary copy.
  4. Add EXIF capture-time metadata.
  5. Upload it to Google Photos.
  6. Move the original screenshot to uploaded/.

You should see output similar to:

New screenshot: /home/YOUR_USERNAME/Pictures/GooglePhotos/upload/screenshot.png
Original file: /home/YOUR_USERNAME/Pictures/GooglePhotos/upload/screenshot.png
Birth time:    2026-09-14T22:00:12.123456-07:00
Embedding EXIF DateTimeOriginal...
Uploading:     /tmp/tmp12345/screenshot.png

Upload successful.
Uploaded: /home/YOUR_USERNAME/Pictures/GooglePhotos/upload/screenshot.png
Enter fullscreen mode Exit fullscreen mode

The file should then be located at:

~/Pictures/GooglePhotos/uploaded/
Enter fullscreen mode Exit fullscreen mode

16. Start automatically with Ubuntu

Create the autostart directory:

mkdir -p ~/.config/autostart
Enter fullscreen mode Exit fullscreen mode

Create:

~/.config/autostart/google-photos-uploader.desktop
Enter fullscreen mode Exit fullscreen mode
[Desktop Entry]
Type=Application
Name=Google Photos Uploader
Comment=Automatically upload screenshots to Google Photos
Exec=/home/YOUR_USERNAME/bin/google-photos-uploader/watch.sh
Terminal=false
X-GNOME-Autostart-enabled=true
Enter fullscreen mode Exit fullscreen mode

Now the watcher will start automatically when the Ubuntu desktop session starts.


17. Final directory structure

The resulting setup is:

~/bin/google-photos-uploader/
├── .venv/
├── auth.py
├── upload.py
└── watch.sh

~/.config/google-photos/
├── client_secret.json
└── token.json

~/.config/autostart/
└── google-photos-uploader.desktop

~/Pictures/GooglePhotos/
├── upload/
└── uploaded/
Enter fullscreen mode Exit fullscreen mode

18. Final behavior

Once everything is running, the workflow is essentially invisible:

┌─────────────────────────┐
│ Take screenshot         │
└────────────┬────────────┘
             │
             ▼
~/Pictures/GooglePhotos/upload/
             │
             │ inotify
             ▼
        watch.sh
             │
             ▼
        upload.py
             │
             ├── Read Birth time
             │
             ├── Add EXIF
             │   DateTimeOriginal
             │   Timezone offset
             │
             ▼
       Google Photos
             │
             ▼
~/Pictures/GooglePhotos/uploaded/
Enter fullscreen mode Exit fullscreen mode

The key detail is that the screenshot's actual local capture time and timezone are preserved, rather than allowing Google Photos to interpret the timestamp as UTC.

Top comments (1)

Collapse
 
raknaos profile image
Raknaos

The Birth timestamp detail is the part of this that most screenshot-to-cloud pipelines get wrong silently: if you don't carry the offset into the metadata, every photo lands at the upload moment and your library reorders itself by network conditions instead of by when you took the shot. Falling back to GNU stat -c %w because os.stat() doesn't expose st_birthtime on that Ubuntu is exactly the kind of thing you only learn by building it.

Two failure modes I'd watch: inotify reports CLOSE_WRITE for most screenshot tools, but some write through a temp file and rename — a raw CREATE watcher will pick up a half-written PNG and embed metadata into a corrupt copy. And if the uploader dies between a successful upload and the move into uploaded/, the next pass re-uploads the same file. Do you dedupe by checksum before sending, or is the local uploaded/ folder the whole idempotency story?