Immich × Databricks

Ingest your Immich photo library via the REST API into a Bronze / Silver / Gold medallion lakehouse — with incremental updates, SCD deletion tracking, scheduled jobs, dashboards, and an interactive map.

Values like this ✏ need to match your setup. Hover for guidance.

00 — Bootstrap secrets (run once, then delete)

Creates the Databricks secret scope and stores your Immich credentials. Uses the Databricks REST API directly — no CLI needed. Fill in the four highlighted values, run once, then delete the cell immediately.

Delete this cell after running. Your credentials are now in Databricks Secrets.
Pythoncell 00 — create secrets (run once, then delete)
# Section 00 — run once to store Immich credentials in Databricks Secrets
import requests

# ── Fill these in, run once, then DELETE this cell ────────────────────
DATABRICKS_PAT  = "dapi..."
WORKSPACE_URL   = "https://adb-xxxxxxxx.azuredatabricks.net"
IMMICH_BASE_URL = "https://your-immich-url.com"
IMMICH_API_KEY  = "your_immich_api_key_here"
# ─────────────────────────────────────────────────────────────────────

headers = {"Authorization": f"Bearer {DATABRICKS_PAT}", "Content-Type": "application/json"}
base = f"{WORKSPACE_URL}/api/2.0/secrets"

# Create the secret scope — harmless if it already exists
def create_scope(name):
    r = requests.post(f"{base}/scopes/create", headers=headers,
                      json={"scope": name, "initial_manage_principal": "users"})
    if r.status_code == 200: print(f"Scope '{name}' created.")
    elif "RESOURCE_ALREADY_EXISTS" in r.text: print(f"Scope '{name}' already exists.")
    else: raise Exception(r.text)

# Store a single key/value pair in the scope
def put_secret(scope, key, value):
    r = requests.post(f"{base}/put", headers=headers,
                      json={"scope": scope, "key": key, "string_value": value})
    if r.status_code == 200: print(f"  secret '{key}' stored.")
    else: raise Exception(r.text)

create_scope("immich")
put_secret("immich", "api_key",  IMMICH_API_KEY)
put_secret("immich", "base_url", IMMICH_BASE_URL)
print("\nDone. Now DELETE this cell.")
Expected output
Scope 'immich' created. secret 'api_key' stored. secret 'base_url' stored. Done. Now DELETE this cell.

01 — Workspace setup & secrets

Two cells to run each session. Cell 1a holds your credentials — set it once and leave it alone. Cell 1b does everything else: imports, schemas, SQL context, and connectivity check.

Cell 1a only needs updating if your Immich URL or API key changes. Cell 1b can be freely updated without touching your credentials.
Pythoncell 01a — credentials (set once, leave alone)
# Section 01a — load credentials from Databricks Secrets into session variables
import requests

# ── Your Immich credentials ───────────────────────────────────────────────
# Read from Databricks Secrets — never hardcode these in a notebook cell
API_KEY  = dbutils.secrets.get(scope="immich", key="api_key")
BASE_URL = dbutils.secrets.get(scope="immich", key="base_url").rstrip("/")
HEADERS  = {"x-api-key": API_KEY, "Accept": "application/json"}  # Immich expects the API key in this header on every request

print("Credentials loaded.")
Expected output
Credentials loaded.
Pythoncell 01b — workspace setup
# Section 01b — imports, catalog/schema setup, SQL context, connectivity check
import json
from datetime import datetime, timezone, timedelta
from pyspark.sql import SparkSession
from pyspark.sql.functions import (
    col, from_json, to_timestamp, year, month,
    dayofmonth, lower, current_timestamp, lit, when, count
)
from pyspark.sql.types import (
    StructType, StructField, StringType, LongType,
    BooleanType, DoubleType, IntegerType, TimestampType
)

# Re-use the existing Spark session if one is already running
spark = SparkSession.builder.getOrCreate()

# ── Catalog & schemas ─────────────────────────────────────────────────────
# Discover the catalog name dynamically — avoids hardcoding "workspace"
CATALOG = spark.sql("SELECT current_catalog()").collect()[0][0]
print(f"Catalog : {CATALOG}")

# Create the three medallion schemas if they don't exist yet
for schema in ["immich_bronze", "immich_silver", "immich_gold"]:
    spark.sql(f"CREATE SCHEMA IF NOT EXISTS {CATALOG}.{schema}")
    print(f"  schema ready: {CATALOG}.{schema}")

# ── Set SQL context ───────────────────────────────────────────────────────
# Without this, SQL cells resolve against the 'default' catalog and fail
spark.sql(f"USE CATALOG {CATALOG}")
print(f"  catalog set: {CATALOG}")

# ── Connectivity check ────────────────────────────────────────────────────
# Quick ping to confirm the API key and URL are correct
resp = requests.get(f"{BASE_URL}/api/server/statistics", headers=HEADERS)
resp.raise_for_status()  # Raises an exception immediately if the request fails
stats = resp.json()
print(f"\nImmich connection OK")
print(f"  Photos : {stats.get('photos','?')}")
Expected output
Catalog : workspace schema ready: workspace.immich_bronze schema ready: workspace.immich_silver schema ready: workspace.immich_gold SQL context: workspace.immich_silver Immich connection OK Photos : 12453

02 — Reset Drop and recreate all layers

Only run when you want a completely clean slate. Drops every table including deletion history. Skip on normal runs.

Destructive. Permanently deletes all data in bronze, silver, and gold.
Pythoncell 02 — reset all layers
# Section 02 — destructive reset: drops all bronze/silver/gold tables
# Loop over all three medallion schemas and drop every table in each
for schema in ["immich_bronze", "immich_silver", "immich_gold"]:
    full = f"{CATALOG}.{schema}"
    tables = spark.sql(f"SHOW TABLES IN {full}").collect()
    if tables:
        for row in tables:
            spark.sql(f"DROP TABLE IF EXISTS {full}.{row['tableName']}")
            print(f"  dropped: {full}.{row['tableName']}")
    else:
        print(f"  {full}: already empty")
print("\nAll layers cleared.")
First run
workspace.immich_bronze: already empty workspace.immich_silver: already empty workspace.immich_gold: already empty All layers cleared.
Subsequent runs
dropped: workspace.immich_bronze.assets_raw dropped: workspace.immich_bronze.albums_raw dropped: immich_silver.assets dropped: immich_silver.albums dropped: immich_gold.photos_by_month dropped: immich_gold.camera_summary dropped: immich_gold.geography dropped: immich_gold.album_stats dropped: immich_gold.ratings_summary All layers cleared.

03 — Explore the raw API response

Fetch one asset to inspect the exact structure your Immich version returns. withExif: True is required — without it the search endpoint omits exifInfo.

Pythoncell 03 — inspect a single asset
# Section 03 — explore: inspect the raw API response structure for one asset
resp = requests.post(f"{BASE_URL}/api/search/metadata", headers=HEADERS,
                     json={"page": 1, "size": 1, "withExif": True})
resp.raise_for_status()
data = resp.json()

assets_block = data.get("assets", {})
envelope = {k: v for k, v in assets_block.items() if k != "items"}
print("=== Pagination envelope ===")
print(json.dumps(envelope, indent=2))
print("\n=== Full structure of one asset ===")
print(json.dumps(assets_block["items"][0], indent=2))
Expected output (abbreviated)
=== Pagination envelope === { "total": 1, "count": 1, "nextPage": "2", "facets": [] } === Full structure of one asset === { "id": "5a92104a-b6db-...", "originalFileName": "20260524-DSC06146.jpg", "type": "IMAGE", "fileCreatedAt": "2026-05-24T07:29:15.627Z", "localDateTime": "2026-05-24T17:29:15.627Z", "isFavorite": false, "visibility": "timeline", "exifInfo": { "make": "SONY", "model": "ILCE-7CM2", "lensModel": "FE 28-60mm F4-5.6", "fNumber": 4.0, "iso": 4000, "latitude": -31.959281, "longitude": 115.89533, "city": "Burswood", "country": "Australia", "fileSizeInByte": 6637963, "exifImageWidth": 7008, "exifImageHeight": 4672, "rating": 1, "timeZone": "UTC+10" } }

04 — Analyse field coverage

Sample 100 assets to see which fields are populated in your library. Useful before building gold queries that depend on sparse fields like city or rating.

Pythoncell 04 — field coverage
# Section 04 — explore: check which fields are populated across a sample of 100 assets
resp = requests.post(f"{BASE_URL}/api/search/metadata", headers=HEADERS,
                     json={"page": 1, "size": 100, "withExif": True})
sample = resp.json().get("assets", {}).get("items", [])
n = len(sample)
top_counts, exif_counts = {}, {}  # Separate counters for top-level fields and nested exifInfo fields

# Count non-empty values for each field across the sample
for asset in sample:
    for k, v in asset.items():
        if v is not None and v != "" and v != [] and v != "0:00:00.00000":
            top_counts[k] = top_counts.get(k, 0) + 1
    for k, v in (asset.get("exifInfo") or {}).items():
        if v is not None and v != "":
            exif_counts[k] = exif_counts.get(k, 0) + 1

print(f"Sample: {n} assets\n")
print("=== Top-level fields ===")
# Print a simple ASCII bar chart — width proportional to fill rate
for k, v in sorted(top_counts.items(), key=lambda x: -x[1]):
    bar = "█" * int(v / n * 30)
    print(f"  {k:<28} {bar:<30} {v}/{n}")
print("\n=== exifInfo fields ===")
for k, v in sorted(exif_counts.items(), key=lambda x: -x[1]):
    bar = "█" * int(v / n * 30)
    print(f"  {k:<28} {bar:<30} {v}/{n}")
Expected output (your values will differ)
Sample: 100 assets === Top-level fields === id ██████████████████████████████ 100/100 originalFileName ██████████████████████████████ 100/100 type ██████████████████████████████ 100/100 exifInfo █████████████████████████████ 98/100 === exifInfo fields === fileSizeInByte █████████████████████████████ 98/100 make ████████████████████████ 82/100 model ████████████████████████ 82/100 city ████████████████ 54/100 rating ████████████████ 52/100

05 — Bronze Full refresh ingestion

Fetches every asset from Immich and merges into bronze using Delta's MERGE. New assets are inserted, changed assets updated in place (Type 1), and assets no longer in Immich are flagged as deleted with a timestamp (Type 2 SCD). Run this weekly.

What bronze stores

  • Raw JSON per asset
  • Ingestion timestamp
  • scd_is_deleted flag
  • scd_deleted_at timestamp

SCD approach

  • Deletions: Type 2 (flag)
  • Metadata changes: Type 1
  • Re-imports: new UUID = new row

Run order

  • 5a → fetch assets
  • 5b → merge assets
  • 5c → fetch albums (optional)
  • 5e → fetch people (optional)
On the very first run the table doesn't exist yet — the cell detects this and falls back to a plain write.
Pythoncell 05a — fetch all assets from Immich
# Section 05a — bronze full refresh: paginate through all assets from the Immich API
def fetch_all_assets(base_url, headers, page_size=500):
    """
    Fetch all assets with EXIF inline.
    withExif=True is required — without it exifInfo is omitted.
    Stops when a page returns fewer items than page_size.
    """
    all_assets, page = [], 1  # Accumulator list and page counter
    while True:
        resp = requests.post(f"{base_url}/api/search/metadata", headers=headers,
                             json={"page": page, "size": page_size, "withExif": True})
        resp.raise_for_status()
        items = resp.json().get("assets", {}).get("items", [])  # Safely navigate the nested response structure
        all_assets.extend(items)
        print(f"  Page {page}: +{len(items)}  (total: {len(all_assets)})")
        # A short page means we've reached the last page — stop paginating
        if len(items) < page_size:
            break
        page += 1
    print(f"\nFetch complete. Total: {len(all_assets)}")
    return all_assets

# ── Run the fetch ───────────────────────────────────────────────────────
print("Fetching all assets from Immich...")
raw_assets = fetch_all_assets(BASE_URL, HEADERS)
Expected output
Fetching all assets from Immich... Page 1: +500 (total: 500) Page 2: +500 (total: 1000) ... Page 25: +453 (total: 12453) Fetch complete. Total: 12453
Pythoncell 05c — merge assets into bronze with SCD deletion tracking
# Section 05c — bronze full refresh: upsert assets into bronze, flag deletions via SCD
import json
from pyspark.sql.functions import from_json, current_timestamp, lit, col
from pyspark.sql.types import StructType, StructField, StringType, TimestampType

raw_schema    = StructType([StructField("raw_json", StringType(), True)])
id_schema     = StructType([StructField("id", StringType())])
BRONZE_ASSETS = f"{CATALOG}.immich_bronze.assets_raw"

# ── Build incoming DataFrame ─────────────────────────────────────────────
# Set of all asset IDs returned by Immich this run — used for deletion detection
incoming_ids = {a["id"] for a in raw_assets}

df_incoming = (
    spark.createDataFrame([(json.dumps(a),) for a in raw_assets], raw_schema)  # Serialise each asset dict to a JSON string — stored raw in bronze
    .withColumn("_p",          from_json(col("raw_json"), id_schema))
    .withColumn("asset_id",    col("_p.id"))
    .withColumn("ingested_at", current_timestamp())
    .withColumn("source",      lit("immich_api"))
    .withColumn("scd_is_deleted",  lit(False))  # All incoming assets are active — deletions are handled in step 2 below
    .withColumn("scd_deleted_at",  lit(None).cast(TimestampType()))
    .drop("_p")
)

# ── First run vs subsequent runs ────────────────────────────────────────
table_exists = spark.catalog.tableExists(BRONZE_ASSETS)

# First run: no table exists yet so just write directly
if not table_exists:
    print("First run — creating bronze table...")
    (df_incoming.write.format("delta").mode("overwrite").saveAsTable(BRONZE_ASSETS))
    print(f"  Wrote {df_incoming.count()} rows")
else:
    df_incoming.createOrReplaceTempView("incoming_assets")

    # Upsert: insert new rows, update changed rows
    spark.sql(f"""
        MERGE INTO {BRONZE_ASSETS} AS target
        USING incoming_assets AS source
        ON target.asset_id = source.asset_id
        WHEN MATCHED THEN UPDATE SET
            target.raw_json    = source.raw_json,
            target.ingested_at = source.ingested_at,
            target.scd_is_deleted  = false,
            target.scd_deleted_at  = NULL
        WHEN NOT MATCHED THEN INSERT *
    """)

    # Flag assets no longer in Immich as deleted
    # Build a comma-separated list of active IDs to use in the UPDATE predicate
    ids_list = ", ".join([f"'{i}'" for i in incoming_ids])
    spark.sql(f"""
        UPDATE {BRONZE_ASSETS}
        SET scd_is_deleted = true, scd_deleted_at = current_timestamp()
        WHERE scd_is_deleted = false
          AND asset_id NOT IN ({ids_list})
    """)

    # Summary stats
    df_b    = spark.table(BRONZE_ASSETS)
    total   = df_b.count()
    active  = df_b.where(col("scd_is_deleted") == False).count()
    deleted = total - active
    print(f"  Total rows : {total}")
    print(f"  Active     : {active}")
    print(f"  Deleted    : {deleted}")
First run
First run — creating bronze table... Wrote 12453 rows
Subsequent runs
Total rows : 12461 Active : 12453 Deleted : 8
Cell 5c is optional. Only run it if you want album data in silver and gold. Cells 5a and 5b are all you need for the main photo pipeline. If you skip 5c, cell 07b will detect the missing table and skip automatically.
Pythoncell 05d — fetch albums & write to bronze
# Section 05d — bronze optional: fetch albums from Immich and write to bronze (run 07b after this)
import json, requests
from pyspark.sql.functions import current_timestamp, lit
from pyspark.sql.types import StructType, StructField, StringType

raw_schema    = StructType([StructField("raw_json", StringType(), True)])
BRONZE_ALBUMS = f"{CATALOG}.immich_bronze.albums_raw"
print("Fetching albums...")
resp = requests.get(f"{BASE_URL}/api/albums", headers=HEADERS)
resp.raise_for_status()
raw_albums = resp.json()
print(f"  Found {len(raw_albums)} albums")

df_albums = (
    spark.createDataFrame([(json.dumps(a),) for a in raw_albums], raw_schema)
    .withColumn("ingested_at", current_timestamp())
    .withColumn("source",      lit("immich_api"))
)
# Albums are small — always full replace rather than merge
(df_albums.write.format("delta").mode("overwrite").saveAsTable(BRONZE_ALBUMS))
print(f"  Wrote {df_albums.count()} rows to bronze.albums_raw")
Expected output
Fetching albums... Found 47 albums Wrote 47 rows to bronze.albums_raw
Cell 5e is optional — only run it if you have named people in Immich (face recognition has been run and people have been given names). Cell 7d and gold cell 09f depend on it.
Pythoncell 05e — fetch people & write to bronze
# Section 05e — bronze optional: fetch named people and their photo counts (run 07d after this)
import json, requests
from pyspark.sql.functions import current_timestamp, lit
from pyspark.sql.types import StructType, StructField, StringType

raw_schema     = StructType([StructField("raw_json", StringType(), True)])
BRONZE_PEOPLE  = f"{CATALOG}.immich_bronze.people_raw"

# Fetch all named people — Immich returns unnamed/hidden people too so we filter
print("Fetching people...")
resp = requests.get(
    f"{BASE_URL}/api/people",
    headers=HEADERS,
    params={"withHidden": False}
)
resp.raise_for_status()
all_people = resp.json().get("people", [])

# Keep only people who have been given a name in Immich
named_people = [p for p in all_people if p.get("name")]
print(f"  Total people detected : {len(all_people)}")
print(f"  Named people          : {len(named_people)}")

# Fetch photo count per person from the statistics endpoint
print("  Fetching per-person statistics...")
for person in named_people:
    stats_resp = requests.get(
        f"{BASE_URL}/api/people/{person['id']}/statistics",
        headers=HEADERS
    )
    if stats_resp.status_code == 200:
        person["statistics"] = stats_resp.json()  # adds assets count
    else:
        person["statistics"] = {"assets": 0}

# Write to bronze — always full replace (small dataset)
df_people = (
    spark.createDataFrame([(json.dumps(p),) for p in named_people], raw_schema)
    .withColumn("ingested_at", current_timestamp())
    .withColumn("source",      lit("immich_api"))
)
(df_people.write.format("delta").mode("overwrite").saveAsTable(BRONZE_PEOPLE))
print(f"  Wrote {df_people.count()} rows to bronze.people_raw")
Expected output
Fetching people... Total people detected : 184 Named people : 23 Fetching per-person statistics... Wrote 23 rows to bronze.people_raw

05b — Bronze Incremental refresh

Only fetches assets modified since the last ingestion. Much faster than a full refresh — use this daily or on-demand after adding new photos. Does not catch deletions; run the full refresh weekly to handle those.

Workflow: incremental daily (or after big imports) → full refresh weekly → always rebuild silver + gold after either.
Pythoncell 05f — incremental asset fetch & merge
# Section 05f — bronze incremental: fetch only assets updated since last run and merge into bronze
import json
from datetime import timedelta
from pyspark.sql.functions import from_json, current_timestamp, lit, col
from pyspark.sql.types import StructType, StructField, StringType, TimestampType

raw_schema    = StructType([StructField("raw_json", StringType(), True)])
BRONZE_ASSETS = f"{CATALOG}.immich_bronze.assets_raw"

# ── Find last ingestion timestamp ───────────────────────────────────────
last_run = spark.sql(f"""
    SELECT MAX(ingested_at) AS ts FROM {BRONZE_ASSETS} WHERE scd_is_deleted = false
""").collect()[0]["ts"]

if last_run is None:
    print("No bronze data found — run the full refresh (cell 5) first.")
else:
    # 1hr buffer for clock skew
    since = (last_run - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%S.000Z")
    print(f"Fetching assets updated since: {since}")

    # ── Fetch only changed/new assets ───────────────────────────────────
    updated, page, page_size = [], 1, 500
    while True:
        resp = requests.post(f"{BASE_URL}/api/search/metadata", headers=HEADERS,
                             json={"page": page, "size": page_size,
                                   "withExif": True, "updatedAfter": since})
        resp.raise_for_status()
        items = resp.json().get("assets", {}).get("items", [])  # Safely navigate the nested response structure
        updated.extend(items)
        print(f"  Page {page}: +{len(items)}  (total: {len(updated)})")
        # A short page means we've reached the last page — stop paginating
        if len(items) < page_size:
            break
        page += 1

    print(f"\n{len(updated)} new/updated assets found")

    # ── Merge into bronze ───────────────────────────────────────────────
    # Only proceed with the merge if there is actually something new
    if updated:
        df_new = (
            spark.createDataFrame([(json.dumps(a),) for a in updated], raw_schema)
            .withColumn("_p", from_json(col("raw_json"), StructType([StructField("id", StringType())])))
            .withColumn("asset_id",    col("_p.id"))
            .withColumn("ingested_at", current_timestamp())
            .withColumn("source",      lit("immich_api"))
            .withColumn("scd_is_deleted",  lit(False))  # All incoming assets are active — deletions are handled in step 2 below
            .withColumn("scd_deleted_at",  lit(None).cast(TimestampType()))
            .drop("_p")
        )
        df_new.createOrReplaceTempView("incoming_assets")
        spark.sql(f"""
            MERGE INTO {BRONZE_ASSETS} AS target
            USING incoming_assets AS source
            ON target.asset_id = source.asset_id
            WHEN MATCHED THEN UPDATE SET
                target.raw_json    = source.raw_json,
                target.ingested_at = source.ingested_at,
                target.scd_is_deleted  = false,
                target.scd_deleted_at  = NULL
            WHEN NOT MATCHED THEN INSERT *
        """)
        print("  Merged into bronze.")
    else:
        print("  Nothing to update.")
Changes found
Fetching assets updated since: 2026-05-31T09:00:00.000Z Page 1: +23 (total: 23) 23 new/updated assets found Merged into bronze.
Nothing new
Fetching assets updated since: 2026-05-31T09:00:00.000Z Page 1: +0 (total: 0) 0 new/updated assets found Nothing to update.

06 — Inspect the bronze layer

Verify what is in bronze including SCD columns. Deleted assets remain in the table with scd_is_deleted = true — this is intentional.

Pythoncell 06 — inspect bronze
# Section 06 — verify: inspect bronze table schema, row counts, and SCD deletion state
df = spark.table(f"{CATALOG}.immich_bronze.assets_raw")
print("=== bronze.assets_raw schema ===")
df.printSchema()

# Count active vs SCD-deleted rows
total   = df.count()
active  = df.where(col("scd_is_deleted") == False).count()
deleted = df.where(col("scd_is_deleted") == True).count()
print(f"\n  Total rows : {total}")
print(f"  Active     : {active}")
print(f"  Deleted    : {deleted}")

# ── Show recently SCD-deleted assets if any ─────────────────────────────
if deleted > 0:
    print("\n=== Recently deleted assets ===")
    (df.where(col("scd_is_deleted") == True)
       .select("asset_id", "scd_deleted_at", "ingested_at")
       .orderBy(col("scd_deleted_at").desc())
       .limit(5).show(truncate=False))

# Preview one raw JSON row to confirm the structure looks right
sample = df.where(col("scd_is_deleted") == False).limit(1).collect()[0]["raw_json"]
print("\n=== Sample raw_json (first 400 chars) ===")
print(sample[:400] + "...")
Expected output
=== bronze.assets_raw schema === root |-- raw_json: string (nullable = true) |-- asset_id: string (nullable = true) |-- ingested_at: timestamp (nullable = false) |-- source: string (nullable = false) |-- scd_is_deleted: boolean (nullable = false) |-- scd_deleted_at: timestamp (nullable = true) Total rows : 12461 Active : 12453 Deleted : 8

07 — Silver Parse & clean

Bronze stores everything as raw JSON strings — silver is where that gets parsed into proper typed columns. These cells read from bronze, filter out SCD-deleted assets, flatten the nested exifInfo struct, and write clean typed data to immich_silver. Run all silver cells after every bronze refresh (full or incremental).

Run order within section 07: 07a first (assets), then 07b/07d if you ran 05d/05e, then 07c (city clean-up) last. The city clean-up in 07c overwrites silver in place so it must come after 07a.
Pythoncell 07a — parse assets to silver
# Section 07a — silver: parse raw JSON into typed columns, filter SCD-deleted rows
from pyspark.sql.functions import (
    col, from_json, to_timestamp, year, month,
    dayofmonth, lower, current_timestamp, count, when
)
from pyspark.sql.types import (
    StructType, StructField, StringType, LongType,
    BooleanType, DoubleType, IntegerType
)

# Define the schema for the nested exifInfo object
# Field names must match exactly what Immich returns (case-sensitive)
exif_schema = StructType([
    StructField("make",             StringType()),
    StructField("model",            StringType()),
    StructField("lensModel",        StringType()),
    StructField("fNumber",          DoubleType()),
    StructField("focalLength",      DoubleType()),
    StructField("iso",              IntegerType()),
    StructField("exposureTime",     StringType()),
    StructField("latitude",         DoubleType()),
    StructField("longitude",        DoubleType()),
    StructField("city",             StringType()),
    StructField("state",            StringType()),
    StructField("country",          StringType()),
    StructField("description",      StringType()),
    StructField("timeZone",         StringType()),
    StructField("dateTimeOriginal", StringType()),
    StructField("rating",           IntegerType()),
    StructField("exifImageWidth",   IntegerType()),
    StructField("exifImageHeight",  IntegerType()),
    StructField("fileSizeInByte",   LongType()),
])
# Top-level asset schema — exifInfo is nested inside as a struct
asset_schema = StructType([
    StructField("id",               StringType()),
    StructField("originalFileName", StringType()),
    StructField("type",             StringType()),
    StructField("fileCreatedAt",    StringType()),
    StructField("localDateTime",    StringType()),
    StructField("isFavorite",       BooleanType()),
    StructField("isArchived",       BooleanType()),
    StructField("visibility",       StringType()),
    StructField("duration",         StringType()),
    StructField("exifInfo",         exif_schema),
])

# ── Parse and flatten ───────────────────────────────────────────────────
print("Parsing bronze assets to silver...")
df_silver = (
    spark.table(f"{CATALOG}.immich_bronze.assets_raw")
    .where(col("scd_is_deleted") == False)  # Only process active assets — SCD-flagged deletions stay in bronze only
    .withColumn("data", from_json(col("raw_json"), asset_schema))  # Parse the raw JSON string into a typed struct column
    .select(
        col("data.id").alias("asset_id"),
        col("data.originalFileName").alias("file_name"),
        lower(col("data.type")).alias("media_type"),
        to_timestamp(col("data.fileCreatedAt")).alias("created_at"),
        to_timestamp(col("data.localDateTime")).alias("local_datetime"),
        year(to_timestamp(col("data.localDateTime"))).alias("year"),
        month(to_timestamp(col("data.localDateTime"))).alias("month"),
        dayofmonth(to_timestamp(col("data.localDateTime"))).alias("day"),
        col("data.isFavorite").alias("is_favourite"),
        col("data.isArchived").alias("is_archived"),
        col("data.visibility").alias("visibility"),
        col("data.duration").alias("video_duration"),
        col("data.exifInfo.make").alias("camera_make"),
        col("data.exifInfo.model").alias("camera_model"),
        col("data.exifInfo.lensModel").alias("lens_model"),
        col("data.exifInfo.fNumber").alias("f_number"),
        col("data.exifInfo.iso").alias("iso"),
        col("data.exifInfo.latitude").alias("latitude"),
        col("data.exifInfo.longitude").alias("longitude"),
        col("data.exifInfo.city").alias("city"),
        col("data.exifInfo.country").alias("country"),
        col("data.exifInfo.fileSizeInByte").alias("file_size_bytes"),
        col("data.exifInfo.exifImageWidth").alias("width_px"),
        col("data.exifInfo.exifImageHeight").alias("height_px"),
        col("data.exifInfo.timeZone").alias("timezone"),
        col("data.exifInfo.rating").alias("rating"),
        col("ingested_at")
    )
    .dropDuplicates(["asset_id"])  # Defensive dedup in case of any duplicate IDs
    .where(col("asset_id").isNotNull())  # Drop any rows where JSON parsing failed
)
# ── Write and summarise ─────────────────────────────────────────────────
(df_silver.write.format("delta").mode("overwrite").saveAsTable(f"{CATALOG}.immich_silver.assets"))

# Summary counts
total    = df_silver.count()
with_gps = df_silver.where(col("latitude").isNotNull()).count()
rated    = df_silver.where(col("rating").isNotNull()).count()
print(f"  Total  : {total}")
print(f"  GPS    : {with_gps} ({round(with_gps/total*100)}%)")
print(f"  Rated  : {rated} ({round(rated/total*100)}%)")
print(f"\nWritten to: {CATALOG}.immich_silver.assets")
Expected output
Parsing bronze assets to silver... Total : 12453 Images : 12453 GPS : 8941 (72%) Rated : 6208 (50%) Written to: immich_silver.assets
Pythoncell 07b — parse albums to silver
# Section 07b — silver optional: parse bronze albums into typed silver table
# Requires cell 05d (album fetch) to have been run first.
# Skips gracefully if bronze.albums_raw doesn't exist yet.
if not spark.catalog.tableExists(f"{CATALOG}.immich_bronze.albums_raw"):
    print("Skipping — bronze.albums_raw not found. Run cell 05d first.")
else:
    album_schema = StructType([
        StructField("id",            StringType()),
        StructField("albumName",     StringType()),
        StructField("description",   StringType()),
        StructField("assetCount",    IntegerType()),
        StructField("startDate",     StringType()),
        StructField("endDate",       StringType()),
        StructField("shared",        BooleanType()),
        StructField("hasSharedLink", BooleanType()),
    ])
    # Parse raw JSON into typed columns and write to silver
    df_albums = (
        spark.table(f"{CATALOG}.immich_bronze.albums_raw")
        .withColumn("data", from_json(col("raw_json"), album_schema))
        .select(
            col("data.id").alias("album_id"),
            col("data.albumName").alias("album_name"),
            col("data.description").alias("description"),
            col("data.assetCount").alias("asset_count"),
            to_timestamp(col("data.startDate")).alias("start_date"),
            to_timestamp(col("data.endDate")).alias("end_date"),
            col("data.shared").alias("is_shared"),
            col("data.hasSharedLink").alias("has_shared_link"),
            col("ingested_at")
        )
        .dropDuplicates(["album_id"])
    )
    (df_albums.write.format("delta").mode("overwrite").saveAsTable(f"{CATALOG}.immich_silver.albums"))
    print(f"  Albums : {df_albums.count()}")
    print(f"  Shared : {df_albums.where(col('is_shared') == True).count()}")
Expected output — albums exist
Albums : 47 Shared : 12
Expected output — albums not yet fetched
Skipping — bronze.albums_raw not found. Run cell 05d first.
Pythoncell 07c — clean city names (strip CBD / city centre suffixes)
# Section 07c — silver: strip CBD / city centre suffixes from city names in place
from pyspark.sql.functions import regexp_replace, col

# Strip common CBD / city centre suffixes from city names:
# "Melbourne CBD" → "Melbourne"
# "Sydney city centre" → "Sydney"
# "Brisbane Central Business District" → "Brisbane"
# Read silver, apply the regex substitution, write back in place
df_cleaned = spark.table(f"{CATALOG}.immich_silver.assets").withColumn(
    "city",
    regexp_replace(col("city"), " (CBD|city centre|Central Business District)$", "")
)

(df_cleaned.write
    .format("delta")
    .mode("overwrite")
    .saveAsTable(f"{CATALOG}.immich_silver.assets"))

# Show distinct cities so you can spot-check the result
print("City clean-up applied. Distinct cities now in silver:")
spark.table(f"{CATALOG}.immich_silver.assets").select("city").distinct().orderBy("city").limit(30).show(truncate=False)
Expected output
City clean-up applied. Sample of distinct cities now in silver: +------------------+ |city | +------------------+ |Adelaide | |Brisbane | |Melbourne | |Perth | |Sydney | +------------------+
Pythoncell 07d — parse people to silver
# Section 07d — silver optional: parse bronze people into typed silver table
from pyspark.sql.functions import col, from_json, current_timestamp
from pyspark.sql.types import StructType, StructField, StringType, BooleanType, IntegerType

# Only runs if cell 05e has been run first
if not spark.catalog.tableExists(f"{CATALOG}.immich_bronze.people_raw"):
    print("Skipping — bronze.people_raw not found. Run cell 05e first.")
else:
    stats_schema = StructType([StructField("assets", IntegerType())])
    person_schema = StructType([
        StructField("id",          StringType()),
        StructField("name",        StringType()),
        StructField("birthDate",   StringType()),
        StructField("isFavorite",  BooleanType()),
        StructField("isHidden",    BooleanType()),
        StructField("statistics",  stats_schema),
    ])

    df_people = (
        spark.table(f"{CATALOG}.immich_bronze.people_raw")
        .withColumn("data", from_json(col("raw_json"), person_schema))
        .select(
            col("data.id").alias("person_id"),
            col("data.name").alias("name"),
            col("data.birthDate").alias("birth_date"),
            col("data.isFavorite").alias("is_favourite"),
            col("data.isHidden").alias("is_hidden"),
            col("data.statistics.assets").alias("photo_count"),
            col("ingested_at")
        )
        .dropDuplicates(["person_id"])
        .where(col("person_id").isNotNull())
    )
    (df_people.write.format("delta").mode("overwrite").saveAsTable(f"{CATALOG}.immich_silver.people"))
    print(f"  People : {df_people.count()}")
    df_people.show(truncate=False)
Expected output
People : 23 +----------+----------------+----------+------------+---------+-----------+ |person_id |name |birth_date|is_favourite|is_hidden|photo_count| +----------+----------------+----------+------------+---------+-----------+ |a1b2c3... |Tim Hocking |null |false |false |1842 | |d4e5f6... |Jane Smith |null |false |false |743 | +----------+----------------+----------+------------+---------+-----------+

08 — Inspect the silver layer

Pythoncell 08 — inspect silver
# Section 08 — verify: check silver schema, null rates, and sample rows
df = spark.table(f"{CATALOG}.immich_silver.assets")
print("=== silver.assets schema ===")
df.printSchema()

print("=== 5 sample rows ===")
(df.select("asset_id", "file_name", "media_type", "year", "month",
           "camera_make", "camera_model", "city", "country",
           "file_size_bytes", "rating")
   .limit(5).show(truncate=35))

print("=== Null counts in key columns ===")
# Check null rates on columns that are commonly sparse
cols_to_check = ["camera_make", "camera_model", "city", "country",
                 "latitude", "longitude", "year", "file_size_bytes",
                 "rating", "timezone"]
# Aggregate null count for each column in one pass
null_counts = df.select([
    count(when(col(c).isNull(), c)).alias(c) for c in cols_to_check
]).collect()[0].asDict()
total = df.count()
print(f"  (out of {total} rows)")
# Print each column's null rate as a percentage
for c, n in null_counts.items():
    pct = round(n / total * 100)
    print(f"  {c:<20} {n:>6} nulls  ({pct}%)")
Expected output
=== silver.assets schema === root |-- asset_id: string |-- file_name: string |-- media_type: string |-- created_at: timestamp |-- local_datetime: timestamp |-- year: integer |-- month: integer |-- day: integer |-- is_favourite: boolean |-- camera_make: string |-- camera_model: string |-- city: string |-- country: string |-- latitude: double |-- longitude: double |-- file_size_bytes: long |-- timezone: string |-- rating: integer |-- ingested_at: timestamp === Null counts in key columns === (out of 12453 rows) camera_make 1823 nulls (15%) city 3512 nulls (28%) country 3512 nulls (28%) latitude 3512 nulls (28%) year 0 nulls (0%) file_size_bytes 154 nulls (1%) rating 6245 nulls (50%) timezone 420 nulls (3%)
Pythoncell 08b — photos to delete (unrated or non-first-place)
# Section 08b — photos to delete in Immich
# All photos should be rated 1 (first place) before upload
# Anything unrated or rated 2+ should not exist — find them here then go delete in Immich
df = spark.table(f"{CATALOG}.immich_silver.assets")
total = df.count()

# Photos that are unrated or not first place
to_delete = df.where(
    col("rating").isNull() | (col("rating") != 1)
)
n = to_delete.count()
print(f"Photos to delete : {n} of {total} ({round(n/total*100)}%)")

if n == 0:
    print("  All good — every photo is rated 1.")
else:
    # Break down by rating value so you know what you're dealing with
    print("\n=== Breakdown by rating ===")
    (to_delete
        .groupBy("rating")
        .count()
        .orderBy("rating")
        .show())

    # Break down by year to see when the problem photos are from
    print("=== Breakdown by year ===")
    (to_delete
        .groupBy("year")
        .count()
        .orderBy("year")
        .show())

    # Full list — take this to Immich and delete these
    print("=== Full list (most recent first) ===")
    (to_delete
        .select("file_name", "rating", "local_datetime", "city", "country")
        .orderBy(col("local_datetime").desc())
        .show(50, truncate=False))
Expected output — all good
Photos to delete : 0 of 12453 (0%) All good — every photo is rated 1.
Expected output — problems found
Photos to delete : 8 of 12453 (0%) === Breakdown by rating === +------+-----+ |rating|count| +------+-----+ | null| 5| | 2| 2| | 3| 1| +------+-----+ === Breakdown by year === +----+-----+ |year|count| +----+-----+ |2024| 3| |2025| 5| +----+-----+ === Full list (most recent first) === +------------------------+------+-------------------+-----+---------+ |file_name |rating|local_datetime |city |country | +------------------------+------+-------------------+-----+---------+ |20260520-DSC05981.jpg |null |2026-05-20 14:22:11|Perth|Australia| +------------------------+------+-------------------+-----+---------+

09 — Gold Analytics tables

Purpose-built aggregations ready for dashboards. Note: lower rating = better (1 = first place, 5 = last) — queries sort accordingly.

These cells use spark.sql(f"...") with {CATALOG} interpolated — the same pattern as bronze and silver. No SQL cell context issues.
Pythoncell 09 — pre-flight check (run if gold returns no rows)
# Section 09 — debug: run this if gold tables return no rows after 09a–09f
# Run this before 9a to confirm silver has data and the gold query will work
df = spark.table(f"{CATALOG}.immich_silver.assets")

print("=== Row count ===")
print(f"  Total rows : {df.count()}")

print("=== year distinct values ===")
df.groupBy("year").count().orderBy("year").show()

print("=== Direct test of 9a query logic ===")
spark.sql(f"""
    SELECT year, month, COUNT(*) AS asset_count
    FROM {CATALOG}.immich_silver.assets
        GROUP BY year, month
    ORDER BY year, month
    LIMIT 5
""").show()

print("=== Current schema context ===")
print(spark.sql("SELECT current_catalog(), current_schema()").collect()[0])
SQLcell 09a — gold: photos by year & month
-- Section 09a — gold: photo counts aggregated by year and month
-- Count photos per year/month with storage and favourite stats
-- Library is images only so no media_type filter needed
CREATE OR REPLACE TABLE immich_gold.photos_by_month AS
SELECT
    year,
    month,
    COUNT(*)                                    AS photo_count,
    COUNT(*) FILTER (WHERE is_favourite = true) AS favourite_count,
    ROUND(SUM(file_size_bytes) / 1e9, 2)        AS total_size_gb,
    COUNT(DISTINCT camera_make)                 AS distinct_cameras
FROM immich_silver.assets
GROUP BY year, month
ORDER BY year, month;
SQLcell 09b — gold: geography
-- Section 09b — gold: photo counts by country and city with avg GPS coordinates
-- Aggregate photo counts by country and city
-- avg_lat/avg_lon: NULL for rows with no GPS data (used by Leaflet map in section 13)
CREATE OR REPLACE TABLE immich_gold.geography AS
SELECT
    COALESCE(country, 'No GPS data') AS country,  -- group nulls together
    city,
    COUNT(*)                         AS photo_count,
    COUNT(DISTINCT camera_model)     AS cameras_used,
    MIN(local_datetime)              AS earliest_photo,
    MAX(local_datetime)              AS latest_photo,
    AVG(latitude)                    AS avg_lat,   -- NULL when no GPS data
    AVG(longitude)                   AS avg_lon    -- NULL when no GPS data
FROM immich_silver.assets
GROUP BY country, city
ORDER BY photo_count DESC;
SQLcell 09c — gold: camera gear
-- Section 09c — gold: shooting stats per camera body
-- Summarise shooting stats per camera body
-- Useful for tracking when you switched gear and how each body was used
CREATE OR REPLACE TABLE immich_gold.camera_summary AS
SELECT
    COALESCE(camera_make,  'Unknown') AS make,
    COALESCE(camera_model, 'Unknown') AS model,
    COUNT(*)                          AS photo_count,
    ROUND(AVG(f_number), 1)           AS avg_f_number,
    ROUND(AVG(iso), 0)                AS avg_iso,
    MIN(year)                         AS first_used_year,
    MAX(year)                         AS last_used_year
FROM immich_silver.assets
GROUP BY camera_make, camera_model
ORDER BY photo_count DESC;
SQLcell 09d — gold: ratings summary (1=best)
-- Section 09d — gold: rating distribution with action flag for non-first-place photos
-- Rating convention: 1 = first place (best), 5 = last
-- This library should only contain rating=1 photos
-- The action column flags anything else for review/deletion in Immich
CREATE OR REPLACE TABLE immich_gold.ratings_summary AS
SELECT
    rating,
    COUNT(*)                     AS photo_count,
    COUNT(DISTINCT country)      AS countries,
    COUNT(DISTINCT camera_model) AS cameras,
    MIN(year)                    AS earliest_year,
    MAX(year)                    AS latest_year,
    CASE WHEN rating = 1 THEN 'OK' ELSE 'Review / delete' END AS action
FROM immich_silver.assets
WHERE rating IS NOT NULL
GROUP BY rating
ORDER BY rating ASC;
SQLcell 09e — gold: album stats
-- Section 09e — gold optional: album stats (requires cells 05d and 07b)
-- Only run if albums were fetched (cell 05d) and parsed (cell 07b)
CREATE OR REPLACE TABLE immich_gold.album_stats AS
SELECT
    album_name,
    asset_count,
    is_shared,
    has_shared_link,
    start_date,
    end_date,
    DATEDIFF(end_date, start_date) AS date_span_days  -- days from first to last photo in the album
FROM immich_silver.albums
ORDER BY asset_count DESC;
SQLcell 09f — gold: people (requires cell 05e + 7d)
-- Section 09f — gold optional: people ranked by photo count (requires cells 05e and 07d)
-- Ranked list of named people by photo count
-- Only run if bronze.people_raw was populated by cell 05e
CREATE OR REPLACE TABLE immich_gold.people_summary AS
SELECT
    name,
    photo_count,
    birth_date,
    is_favourite,
    -- rank by photo count so the most-photographed person is first
    RANK() OVER (ORDER BY photo_count DESC) AS rank
FROM immich_silver.people
ORDER BY photo_count DESC;
SQLcell 09g — gold: lens summary
-- Section 09g — gold: lens usage stats
-- Separate table from camera_summary since one body is used with multiple lenses
-- Joined back to camera_summary to show which body each lens was used with
CREATE OR REPLACE TABLE immich_gold.lens_summary AS
SELECT
    COALESCE(lens_model, 'Unknown')  AS lens,
    COALESCE(camera_make, 'Unknown') AS camera_make,
    COALESCE(camera_model, 'Unknown') AS camera_model,
    COUNT(*)                           AS photo_count,
    ROUND(AVG(f_number), 1)            AS avg_f_number,
    ROUND(AVG(iso), 0)                 AS avg_iso,
    MIN(year)                          AS first_used_year,
    MAX(year)                          AS last_used_year
FROM immich_silver.assets
GROUP BY lens_model, camera_make, camera_model
ORDER BY photo_count DESC;

10 — Explore your data

Catalog context is already set from cell 1 — these SQL cells will work as-is.

SQLcell 10a — photos per year
-- Section 10a — explore: total photos per year
-- Total photos per year — quick overview of library growth over time
SELECT year, SUM(photo_count) AS total_photos
FROM immich_gold.photos_by_month
GROUP BY year
ORDER BY year;
SQLcell 10b — top 20 countries
-- Section 10b — explore: countries ranked by photo count
-- Countries ranked by photo count — excludes assets with no GPS data
SELECT country, SUM(photo_count) AS photos
FROM immich_gold.geography
WHERE country != 'No GPS data'
GROUP BY country
ORDER BY photos DESC
LIMIT 20;
SQLcell 10c — top 30 cities
-- Section 10c — explore: cities ranked by photo count
-- Cities ranked by photo count — only rows where city was resolved from GPS
SELECT city, country, SUM(photo_count) AS photos
FROM immich_gold.geography
WHERE city IS NOT NULL
  AND country != 'No GPS data'
GROUP BY city, country
ORDER BY photos DESC
LIMIT 30;
SQLcell 10d — best rated photos by country (1=best)
-- Section 10d — explore: first-place photos by location, highlights non-1-star for review
-- Where are the first-place photos from?
-- needs_review highlights any non-1-star rated photos by location
SELECT
    country,
    city,
    COUNT(*) FILTER (WHERE rating = 1)                         AS first_place,
    COUNT(*) FILTER (WHERE rating IS NOT NULL AND rating <> 1) AS needs_review,
    COUNT(*) FILTER (WHERE rating IS NOT NULL)                AS total_rated
FROM immich_silver.assets
WHERE country IS NOT NULL
GROUP BY country, city
ORDER BY first_place DESC
LIMIT 20;
Pythoncell 10e — chart: photos per year
# Section 10e — explore: matplotlib bar chart of photos per year
import matplotlib.pyplot as plt
df = spark.sql(f"""
    SELECT year, SUM(asset_count) AS photos
    FROM {CATALOG}.immich_gold.photos_by_month
    WHERE year IS NOT NULL
    GROUP BY year ORDER BY year
""").toPandas()
fig, ax = plt.subplots(figsize=(12, 4))
ax.bar(df["year"], df["photos"], color="#1D9E75", width=0.7)
ax.set_xlabel("Year"); ax.set_ylabel("Photos"); ax.set_title("Photos per year")
for _, row in df.iterrows():
    ax.text(row["year"], row["photos"] + 20, str(row["photos"]), ha="center", fontsize=9)
plt.tight_layout(); display(fig)
Pythoncell 10f — chart: top 15 countries
# Section 10f — explore: matplotlib horizontal bar chart of top countries
df = spark.sql(f"""
    SELECT country, SUM(photo_count) AS photos
    FROM {CATALOG}.immich_gold.geography
    WHERE country != 'No GPS data'
    GROUP BY country ORDER BY photos DESC LIMIT 15
""").toPandas()
fig, ax = plt.subplots(figsize=(10, 5))
ax.barh(df["country"][::-1], df["photos"][::-1], color="#378ADD")
ax.set_xlabel("Photos"); ax.set_title("Top 15 countries")
plt.tight_layout(); display(fig)
SQLcell 10g — camera body + lens combinations
-- Section 10g — explore: join lens_summary with camera_summary to see
-- which lenses were used on which bodies, and how each combination performed
SELECT
    l.lens,
    l.camera_make || ' ' || l.camera_model  AS camera,
    l.photo_count                            AS lens_photos,
    c.photo_count                            AS body_total_photos,
    -- what percentage of this body's shots used this lens
    ROUND(l.photo_count * 100.0 / c.photo_count, 1) AS pct_of_body,
    l.avg_f_number,
    l.avg_iso,
    l.first_used_year,
    l.last_used_year
FROM immich_gold.lens_summary l
JOIN immich_gold.camera_summary c
  ON  l.camera_make  = c.make
  AND l.camera_model = c.model
WHERE l.lens != 'Unknown'
  AND c.make       != 'Unknown'
ORDER BY l.photo_count DESC;

11 — Scheduled Automated jobs

The Schedule button in each Databricks notebook lets you run it automatically on a cron schedule. The recommended setup is two separate notebooks: one for the daily incremental run and one for the weekly full refresh.

What to put in each notebook

Daily incremental notebook

  • Cell 01a — credentials
  • Cell 01b — workspace setup
  • Cell 05f — incremental fetch & merge
  • Cell 07a — parse assets to silver
  • Cell 07c — clean city names
  • Cell 09a through 09f — rebuild gold

Catches new and updated photos. Fast — typically under 2 min. Does not detect deletions.

Weekly full refresh notebook

  • Cell 01a — credentials
  • Cell 01b — workspace setup
  • Cell 05a — fetch all assets
  • Cell 05c — merge into bronze
  • Cell 07a — parse assets to silver
  • Cell 07c — clean city names
  • Cell 09a through 09f — rebuild gold

Full sync — catches deletions via SCD flagging. Slower — 10–20 min for large libraries.

Optional cells to add

  • 05d — albums (add to weekly)
  • 05e — people (add to weekly)
  • 07b — parse albums (after 05d)
  • 07d — parse people (after 05e)

Only needed if you use album stats or people gold tables.

Creating the schedule

1

Open the notebook you want to schedule. Click Schedule in the upper-right corner.

2

Click Add schedule. Give it a name — e.g. immich_daily or immich_weekly.

3

Under Schedule, select Custom (Cron) and paste the cron expression below.

4

Set your timezone. Leave compute as Serverless.

5

Click Save. Use the run button on the schedule page to trigger a manual run at any time.

Croncommon schedules
0 3 * * *       # every day at 3am       ← use for daily incremental
0 3 * * 0       # every sunday at 3am    ← use for weekly full refresh
0 */6 * * *     # every 6 hours
0 3 1 * *       # first of every month at 3am
0 3 * * 1-5     # weekdays only at 3am

Cron format: minute hour day-of-month month day-of-week. Adjust the hour to your local timezone offset from UTC — Perth (AWST) is UTC+8, so 3am AWST = 0 19 * * * (previous day UTC).

12 — AI/BI Databricks dashboards

Databricks AI/BI dashboards work in two layers: datasets (SQL queries defined on the Data tab) and visualizations (chart widgets on the canvas that point to a dataset). You define all your queries first as named datasets, then build chart panels by selecting a dataset and configuring the axes.

The X axis and Y axis + buttons in the visualization panel are greyed out until a dataset is selected. You must create your datasets on the Data tab first, then come back to configure the chart.

Step 1 — create a new dashboard

1

In the left sidebar click DashboardsCreate dashboard. Give it a name, e.g. Immich Photo Library.

2

You land on a blank canvas with a Genie prompt box. Ignore Genie — scroll down and click CREATE MANUALLY.

Step 2 — define datasets on the Data tab

Click the Data tab (top left, next to the filter icon). This is where you create named SQL queries that your chart panels will use. For each dataset below: click Create dataset from SQL, paste the query, click Run, give it the name shown, then save.

Dataset SQL uses fully qualified table names. The catalog name is highlighted — change it if yours is different from workspace.
SQLdataset: photos_by_year
SELECT year, SUM(photo_count) AS total_photos
FROM workspace.immich_gold.photos_by_month
WHERE year IS NOT NULL
GROUP BY year ORDER BY year;
SQLdataset: photos_by_month
SELECT year, month, SUM(photo_count) AS photos
FROM workspace.immich_gold.photos_by_month
WHERE year IS NOT NULL
GROUP BY year, month ORDER BY year, month;
SQLdataset: top_countries
SELECT country, SUM(photo_count) AS photos
FROM workspace.immich_gold.geography
WHERE country != 'No GPS data'
GROUP BY country ORDER BY photos DESC LIMIT 20;
SQLdataset: top_cities
SELECT city || ', ' || country AS location,
       SUM(photo_count) AS photos
FROM workspace.immich_gold.geography
WHERE city IS NOT NULL AND country != 'No GPS data'
GROUP BY city, country ORDER BY photos DESC LIMIT 30;
SQLdataset: camera_gear
SELECT make || ' ' || model AS camera, photo_count, first_used_year, last_used_year
FROM workspace.immich_gold.camera_summary
WHERE make != 'Unknown'
ORDER BY photo_count DESC LIMIT 15;
SQLdataset: ratings
SELECT rating, photo_count, action
FROM workspace.immich_gold.ratings_summary
ORDER BY rating ASC;
SQLdataset: lens_summary
SELECT lens, camera_make, camera_model, photo_count,
       avg_f_number, avg_iso, first_used_year, last_used_year
FROM workspace.immich_gold.lens_summary
WHERE lens != 'Unknown'
ORDER BY photo_count DESC;
SQLdataset: totals (for counters)
SELECT
    SUM(photo_count)                          AS total_photos,
    ROUND(SUM(total_size_gb), 1)                AS total_gb,
    (SELECT COUNT(DISTINCT country) FROM workspace.immich_gold.geography
     WHERE country != 'No GPS data')         AS countries_visited,
    (SELECT COUNT(*) FROM workspace.immich_silver.assets
     WHERE rating = 1)                            AS first_place_photos
FROM workspace.immich_gold.photos_by_month;

Step 3 — add visualization panels to the canvas

Click the page tab (e.g. "Untitled page") to go back to the canvas. Click the bar chart icon in the bottom toolbar to add a visualization widget. The right panel opens with Dataset, Visualization, X axis, Y axis etc.

1

In the right panel, click the Dataset dropdown and select the dataset you created for this panel.

2

Choose a Visualization type from the dropdown — Bar, Heatmap, Counter etc.

3

Click + next to X axis and select the field. Then click + next to Y axis and select the field. The chart renders immediately.

4

Repeat — add another visualization widget from the toolbar for each panel below.

5

Drag and resize panels to arrange. Click Publish at the top right for a shareable URL.

Recommended panels

Photos per year

  • Dataset: photos_by_year
  • Type: Bar
  • X: year
  • Y: total_photos

Monthly heatmap

  • Dataset: photos_by_month
  • Type: Heatmap
  • X: month
  • Y: year
  • Color: photos

Top countries

  • Dataset: top_countries
  • Type: Bar (horizontal)
  • X: photos
  • Y: country

Top cities

  • Dataset: top_cities
  • Type: Bar (horizontal)
  • X: photos
  • Y: location

Camera gear

  • Dataset: camera_gear
  • Type: Bar (horizontal)
  • X: photo_count
  • Y: camera

Ratings

  • Dataset: ratings
  • Type: Bar
  • X: rating
  • Y: photo_count
  • Color: action

Lens usage

  • Dataset: lens_summary
  • Type: Bar (horizontal)
  • X: photo_count
  • Y: lens

Counter widgets

Add a visualization widget, set type to Counter, select the totals dataset, and set the Value field to the column you want displayed. Add one counter per metric — total photos, storage, countries visited, first place photos.

13 — Bonus Photo map — Databricks App

This section deploys a Leaflet map of your photo locations as a Databricks App — a hosted web app that runs inside your Databricks workspace, reads live from your gold table, and requires no external server.

Databricks Apps are available in Free Edition. The app reads directly from immich_gold.geography via the Databricks SQL connector — no file export needed.

How it works

The app is a small Python Flask server that queries your gold geography table and returns the data as JSON to a Leaflet map running in the browser. Databricks hosts and serves it — you just deploy and open the URL.

Step 1 — export geography to JSON (cell 13a)

Run this cell to export your geography gold table to /Volumes or /FileStore as a JSON file the app will read on startup.

Pythoncell 13a — export geography data to JSON
# Section 13a — map: export geography gold table to JSON for the Leaflet map
import json

# Pull geography rows that have GPS coordinates
geo_df = spark.sql(f"""
    SELECT country, city,
           ROUND(avg_lat, 4) AS lat,
           ROUND(avg_lon, 4) AS lon,
           photo_count, earliest_photo, latest_photo
    FROM {CATALOG}.immich_gold.geography
    WHERE avg_lat IS NOT NULL AND avg_lon IS NOT NULL
      AND country != 'No GPS data'
    ORDER BY photo_count DESC
""").toPandas()

total_photos    = int(geo_df["photo_count"].sum())
total_locations = len(geo_df)
print(f"Locations with GPS : {total_locations}")
print(f"Photos mapped      : {total_photos}")

# Serialise to JSON — embedded directly in the app
locations_json = json.dumps(geo_df.to_dict(orient="records"))

# Save to FileStore so the app can read it
dbutils.fs.put("/FileStore/immich_map_data.json", locations_json, overwrite=True)
print("Saved to /FileStore/immich_map_data.json")

Step 2 — create the Databricks App (cell 13b)

This cell generates the app files and prints instructions. The app is a self-contained HTML file with Leaflet embedded — no Python server needed.

Pythoncell 13b — generate map HTML and save to FileStore
# Section 13b — map: build self-contained Leaflet HTML and save to FileStore
# Build a self-contained dark-themed Leaflet map with circle markers
# Marker size is proportional to photo count at that location
html_content = f"""
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Photo Map</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
<style>
  *{{box-sizing:border-box;margin:0;padding:0}}
  body{{background:#0f1117;color:#e8eaf0;font-family:system-ui,sans-serif}}
  #header{{padding:1rem 1.5rem;background:#161b27;border-bottom:1px solid rgba(255,255,255,.08)}}
  #header h1{{font-size:18px;font-weight:400}}
  #header p{{font-size:13px;color:#8b90a0;margin-top:.2rem}}
  #map{{height:calc(100vh - 62px)}}
  .leaflet-popup-content-wrapper{{background:#1e2535;border:1px solid rgba(255,255,255,.12);color:#e8eaf0;border-radius:8px}}
  .leaflet-popup-tip{{background:#1e2535}}
  .popup-city{{font-size:15px;font-weight:500;margin-bottom:.3rem}}
  .popup-count{{font-size:22px;font-weight:300;color:#4ade9e}}
  .popup-label{{font-size:11px;color:#8b90a0;text-transform:uppercase;letter-spacing:.06em}}
  .popup-dates{{font-size:12px;color:#8b90a0;margin-top:.4rem}}
</style>
</head>
<body>
<div id="header">
  <h1>Photo Map</h1>
  <p>{total_photos:,} photos &middot; {total_locations} locations</p>
</div>
<div id="map"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<script>
const locations = {locations_json};
const map = L.map('map').setView([20,10],2);
L.tileLayer('https://{{s}}.basemaps.cartocdn.com/dark_all/{{z}}/{{x}}/{{y}}{{r}}.png',{{
  attribution:'&copy; OpenStreetMap &copy; CARTO',subdomains:'abcd',maxZoom:19
}}).addTo(map);
const maxCount = Math.max(...locations.map(l=>l.photo_count));
locations.forEach(loc=>{{
  if(!loc.lat||!loc.lon)return;
  const r = Math.max(6, Math.sqrt(loc.photo_count/maxCount)*40);
  const circle = L.circleMarker([loc.lat,loc.lon],{{
    radius:r,fillColor:'#4ade9e',color:'#4ade9e',
    weight:1,opacity:0.8,fillOpacity:0.25
  }}).addTo(map);
  const city  = loc.city||loc.country;
  const from  = loc.earliest_photo?loc.earliest_photo.substring(0,10):'';
  const to    = loc.latest_photo?loc.latest_photo.substring(0,10):'';
  circle.bindPopup(`
    <div class="popup-city">${{city}}</div>
    <div class="popup-label">${{loc.country}}</div>
    <div class="popup-count">${{loc.photo_count.toLocaleString()}}</div>
    <div class="popup-label">photos</div>
    <div class="popup-dates">${{from}} &rarr; ${{to}}</div>
  `);
}});
</script>
</body>
</html>"""

# Save to FileStore
dbutils.fs.put("/FileStore/photo_map.html", html_content, overwrite=True)
print("Saved to /FileStore/photo_map.html")
print("Download via: Catalog → Files → FileStore → photo_map.html")

Step 3 — deploy as a Databricks App

1

In the left sidebar click AppsCreate app.

2

Choose Custom and give it a name — e.g. immich-photo-map.

3

Under Source, point to the photo_map.html file in FileStore, or upload it directly.

4

Click Deploy. Databricks gives you a URL in the format https://<workspace>.databricksapps.com/immich-photo-map.

5

Re-run cells 13a and 13b after each gold rebuild to refresh the map data.

Alternative: download photo_map.html from Catalog → Files → FileStore and open it locally — it's fully self-contained and works offline. Or deploy it to any static file host.