Use Immich to reclaim storage for Google

Google has a pretty generous free plan for storage. However, backups with photos and videos can easily get you to the limit. Thus, an option is to host your own solution for media, using Immich.

We will assume that Immich is already installed on a server in the Cloud or on your own NAS. In our case, it's the latter. Another thing you have to do is install the proper client app for your device.

Get your current media

The first step is to download your media from Google Takeout. Once you complete the form, you will be notified by email or will be able to acces the generated files on the Google Takeout page.

See more:

Upload the media to Immich

To upload the media to Immich, we will not upload the files directly, but use an intermediary tool called immich-go.

  1. Go to the Github page if immich-go, use the releases page to download the latest version for your operating system;
  2. Login to your Immich server and go to the Settings page. There, generate a new API key to use with immich-go;
  3. Go to the folder where you have your Google Takeout archive and run:
    • immich-go upload from-google-photos --server=http://<your immich server address>:2283 --api-key= /path/to/takeout.zip
    • This was not necessary, but one thing I previously did was to extract the files from the archives from google (which were multiple files) and created a new archive.

Another variant for it, which worked for my files was:

~/immich-go upload from-google-photos --server=http://<code><your immich server address>:2283<span style="background-color: initial; font-family: inherit; text-align: initial; white-space: pre-wrap;">/ --api-key=</span></code><api key><code><span style="background-color: initial; font-family: inherit; text-align: initial; white-space: pre-wrap;">--admin-api-key=</span></code><api key> <code><span style="background-color: initial; font-family: inherit; text-align: initial; white-space: pre-wrap;">--pause-immich-jobs=false --include-unmatched ./takeout.zip</span></code>Code language: HTML, XML (xml)

To get the admin API key, you just login with an admin user and generate the API key the same way.

See more:

Google Photos Cleanup

What we want to do now is to delete the files which already exist on Google Photos. We should pay attention to which of the files are already in immich and not delete something which is not backed up. For that, we will start with an AI request:

Create a script which generates a JSON file from the files in the takeout.zip archive and which were also backed up. Sort the files by size, descending. Pass the needed parameters as command line parameters. Use the url in google photos and the url in Immich, so that 1) we can manually access it and delete it or 2) create a browser script to delete those files.

The JSON generator

After some back and forth, the output code is:

#!/usr/bin/env python3
import zipfile
import json
import requests
import os
import glob
import argparse

def format_size(size_in_bytes):
    """Converts bytes to a human-readable string."""
    for unit in ['B', 'KB', 'MB', 'GB']:
        if size_in_bytes < 1024.0:
            return f"{size_in_bytes:.2f} {unit}"
        size_in_bytes /= 1024.0
    return f"{size_in_bytes:.2f} TB"

def get_immich_assets(immich_url, api_key):
    print(f"Connecting to Immich at {immich_url} to fetch uploaded assets...")
    headers = {
        'Accept': 'application/json',
        'x-api-key': api_key,
        'Content-Type': 'application/json'
    }
    
    # Changed from a set to a dictionary to store the Immich asset ID
    immich_assets = {}
    page = 1
    
    while True:
        payload = {
            "page": page,
            "size": 1000
        }
        
        response = requests.post(f"{immich_url}/api/search/metadata", headers=headers, json=payload)
        
        if response.status_code != 200:
            print(f"Failed to fetch assets from Immich (Page {page}): {response.status_code}\n{response.text}")
            break
            
        data = response.json()
        
        items = []
        if isinstance(data, dict):
            if "assets" in data and "items" in data["assets"]:
                items = data["assets"]["items"]
            elif "items" in data:
                items = data["items"]
        elif isinstance(data, list):
            items = data
            
        if not items:
            break 
            
        for a in items:
            original_name = a.get('originalFileName')
            asset_id = a.get('id')
            if original_name and asset_id:
                basename = os.path.splitext(original_name)[0].lower().strip()
                # Link the basename to the specific Immich ID
                immich_assets[basename] = asset_id
                
        print(f"Fetched page {page} ({len(items)} assets)...")
        page += 1
        
    print(f"Successfully retrieved {len(immich_assets)} unique assets from Immich.")
    return immich_assets

def get_zip_file_sizes(zip_paths):
    """Scans all zip files first to map basenames to their actual file sizes."""
    print("Mapping file sizes from Takeout archives...")
    zip_sizes = {}
    
    for zip_path in zip_paths:
        with zipfile.ZipFile(zip_path, 'r') as z:
            for file_info in z.infolist():
                if not file_info.filename.endswith('.json') and not file_info.is_dir():
                    basename = os.path.basename(file_info.filename)
                    name_without_ext = os.path.splitext(basename)[0].lower().strip()
                    
                    if name_without_ext not in zip_sizes or file_info.file_size > zip_sizes[name_without_ext]:
                        zip_sizes[name_without_ext] = file_info.file_size
                        
    return zip_sizes

def main():
    parser = argparse.ArgumentParser(description="Extract Google Photos URLs from Takeout for Immich backed-up files.")
    parser.add_argument('--zip-pattern', type=str, default='takeout*.zip', help="Glob pattern for Takeout zip files (default: takeout*.zip)")
    parser.add_argument('--url', type=str, default='http://10.20.0.20:30041', help="Immich instance URL")
    parser.add_argument('--api-key', type=str, default='G7kptVljSSC0bO9rMve9KGoGKWEKyDmhIgTvAqqwA', help="Immich API Key")
    parser.add_argument('--output', type=str, default='files_to_delete.json', help="Output JSON filename")
    
    args = parser.parse_args()

    takeout_zips = glob.glob(args.zip_pattern)
    if not takeout_zips:
        print(f"Error: No zip files found matching pattern '{args.zip_pattern}'")
        return

    immich_assets = get_immich_assets(args.url, args.api_key)
    if not immich_assets:
        print("Warning: No assets found in Immich. Proceeding to extract all URLs anyway...")
        
    zip_sizes = get_zip_file_sizes(takeout_zips)
    results = []

    for zip_path in takeout_zips:
        print(f"Scanning archive {zip_path} for URLs...")
        with zipfile.ZipFile(zip_path, 'r') as z:
            for file_info in z.infolist():
                if file_info.filename.endswith('.json'):
                    try:
                        with z.open(file_info.filename) as f:
                            data = json.loads(f.read().decode('utf-8'))
                            
                            title = data.get('title')
                            url = data.get('url')
                            
                            if title and url and 'photos.google.com' in url:
                                basename = os.path.splitext(title)[0].lower().strip()
                                
                                if basename in immich_assets or not immich_assets:
                                    size_bytes = zip_sizes.get(basename, 0) 
                                    
                                    # Create the Immich URL if the asset was found
                                    asset_id = immich_assets.get(basename)
                                    immich_web_url = f"{args.url}/photos/{asset_id}" if asset_id else None
                                    
                                    results.append({
                                        "filename": title,
                                        "size_bytes": size_bytes,
                                        "google_url": url,
                                        "immich_url": immich_web_url
                                    })
                    except Exception:
                        continue

    unique_dict = {}
    for item in results:
        existing = unique_dict.get(item['google_url'])
        if not existing or item['size_bytes'] > existing['size_bytes']:
            unique_dict[item['google_url']] = item
            
    unique_results = list(unique_dict.values())
    unique_results.sort(key=lambda x: x['size_bytes'], reverse=True)
    
    for item in unique_results:
        item['size_formatted'] = format_size(item['size_bytes'])

    with open(args.output, 'w') as f:
        json.dump(unique_results, f, indent=4)
        
    print(f"\nDone! Found {len(unique_results)} Google Photos URLs safely backed up in Immich.")
    print(f"Saved to: {args.output} (Sorted largest to smallest)")

if __name__ == "__main__":
    main()Code language: PHP (php)

To use this code, use the following command:

python3 extract_urls.py --zip-pattern "takeout*.zip" --output "large_files.json" --url "http://<immich server address>:30041" --api-key "<api key>"Code language: JavaScript (javascript)

The pop-up opener

Since it's data that will be deleted, we will use a script to run in the console of the browser which will open batches of items to be deleted, one on Google Photos and one on the Immich instance.

Remember to give pop-up rights to the browser instance where you run the script. Also, obviously, update the list inside the backedUpFiles constant.

const backedUpFiles = [
    {
        "filename": "20261222_001942.mp4",
        "size_bytes": 681303891,
        "google_url": "https://photos.google.com/photo/adasdASDASDasdads",
        "immich_url": "http://<IMMICH_SERVER>:30041/photos/asd-123-sadf-3e9edb",
        "size_formatted": "69.74 MB"
    },
];
let currentIndex = 0;
const BATCH_SIZE = 5; // Reduced to 5 since it opens 2 tabs per file (10 tabs total)

function openNextBatch() {
    const limit = Math.min(currentIndex + BATCH_SIZE, backedUpFiles.length);
    if (currentIndex >= limit) {
        alert("All done! You've gone through all the photos.");
        return;
    }

    for (let i = currentIndex; i < limit; i++) {
        // Open Immich first
        if (backedUpFiles[i].immich_url) {
            window.open(backedUpFiles[i].immich_url, '_blank');
        }
        // Open Google Photos right next to it
        if (backedUpFiles[i].google_url) {
            window.open(backedUpFiles[i].google_url, '_blank');
        }
    }

    currentIndex = limit;
    document.getElementById('batch-btn').innerText = `Open Next 5 Pairs (Done: ${currentIndex}/${backedUpFiles.length})`;
}

// 3. Create (or update) the floating button
let btn = document.getElementById('batch-btn');
if (!btn) {
    btn = document.createElement('button');
    btn.id = 'batch-btn';
    document.body.appendChild(btn);
}

btn.innerText = `Open Next 5 Pairs (Done: 0/${backedUpFiles.length})`;
btn.style.cssText = 'position:fixed; top:20px; left:50%; transform:translateX(-50%); z-index:999999; padding:15px 30px; font-size:18px; font-weight:bold; background:#1a73e8; color:white; border:none; border-radius:8px; cursor:pointer; box-shadow: 0 4px 6px rgba(0,0,0,0.3);';
btn.onclick = openNextBatch;

console.log("Button updated! It will now open 5 Immich tabs and 5 Google tabs per click.");Code language: JavaScript (javascript)