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/
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
Create a Python virtual environment:
python3 -m venv .venv
source .venv/bin/activate
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
3. Google Cloud OAuth setup
Create an OAuth 2.0 Client ID in Google Cloud.
The application type should be:
Desktop app
Store the downloaded client secret outside the project:
mkdir -p ~/.config/google-photos
For example:
~/.config/google-photos/client_secret.json
Do not put this file into Git.
4. Google Photos API scope
The uploader uses:
https://www.googleapis.com/auth/photoslibrary.appendonly
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
5. Authenticate with Google
Create:
~/bin/google-photos-uploader/auth.py
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()
Run:
python auth.py
Complete the Google login in the browser.
The OAuth token will be saved to:
~/.config/google-photos/token.json
Protect it:
chmod 600 ~/.config/google-photos/token.json
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
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
The important value is:
Birth
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)
7. Preserve the correct timezone
There is an important timezone issue.
Suppose a screenshot is taken in Vancouver at:
2026-09-14 22:00
Vancouver is UTC-7 during daylight saving time.
That same moment is:
2026-09-15 05:00 UTC
Google Photos/API may represent the timestamp as:
2026-09-15T05:00:00Z
That is correct.
The problem occurs when the local 22:00 is treated as if it were UTC:
22:00 UTC
which would display as:
15:00 Vancouver
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
The important field is:
DateTimeOriginal
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
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()
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
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
The roles are:
upload/
screenshots waiting to be uploaded
uploaded/
screenshots that were successfully uploaded
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
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/
You should see:
Setting up watches.
Watches established.
Create a test file from another terminal:
touch ~/Pictures/GooglePhotos/upload/test-watch.png
The watcher should report:
/home/YOUR_USERNAME/Pictures/GooglePhotos/upload/ CLOSE_WRITE,CLOSE test-watch.png
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
#!/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
Make it executable:
chmod +x ~/bin/google-photos-uploader/watch.sh
15. Test the complete pipeline
Start the watcher:
~/bin/google-photos-uploader/watch.sh
Leave it running.
Take a screenshot and save it into:
~/Pictures/GooglePhotos/upload/
The watcher should:
- Detect the PNG.
- Read its Birth timestamp.
- Create a temporary copy.
- Add EXIF capture-time metadata.
- Upload it to Google Photos.
- 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
The file should then be located at:
~/Pictures/GooglePhotos/uploaded/
16. Start automatically with Ubuntu
Create the autostart directory:
mkdir -p ~/.config/autostart
Create:
~/.config/autostart/google-photos-uploader.desktop
[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
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/
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/
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)
The
Birthtimestamp 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 GNUstat -c %wbecauseos.stat()doesn't exposest_birthtimeon that Ubuntu is exactly the kind of thing you only learn by building it.Two failure modes I'd watch:
inotifyreportsCLOSE_WRITEfor most screenshot tools, but some write through a temp file and rename — a rawCREATEwatcher 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 intouploaded/, the next pass re-uploads the same file. Do you dedupe by checksum before sending, or is the localuploaded/folder the whole idempotency story?