Initial: fingerprint DB + Takeout fetch + ingest flows
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
photo_pipeline.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
test_flow.py
|
||||||
41
.prefectignore
Normal file
41
.prefectignore
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# prefect artifacts
|
||||||
|
.prefectignore
|
||||||
|
|
||||||
|
# python artifacts
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Type checking artifacts
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
*.ipynb_checkpoints/*
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.python-version
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# MacOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Dask
|
||||||
|
dask-worker-space/
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# VCS
|
||||||
|
.git/
|
||||||
|
.hg/
|
||||||
16
README.md
Normal file
16
README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# photo-pipeline
|
||||||
|
|
||||||
|
Prefect-orchestrated photo ingestion: Takeout download → fingerprint DB (dedup) → quality scan → Immich.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
- `photo_db.py` — SQLite fingerprint DB (sha256 exact + phash/dhash near dupes; Takeout archive tracking)
|
||||||
|
- `takeout_fetch.py` — flow: download (aria2c resume) → track sha256 → extract
|
||||||
|
- `photo_ingest.py` — flow: scan dir → check dups → register new
|
||||||
|
- `prefect.yaml` — deployments (fetch, ingest) on photo-pool
|
||||||
|
|
||||||
|
## NixOS note
|
||||||
|
Venv python needs LD_LIBRARY_PATH (gcc libstdc++ + zlib) — see run-python.sh / run-prefect.sh.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
prefect deployment run "takeout-fetch/fetch" --param manifest=/path/urls.txt --param export_id=photos-2026-08
|
||||||
|
prefect deployment run "photo-ingest/ingest" --param base_dir=/mnt/data/takeout/photos-2026-08
|
||||||
163
photo_db.py
Normal file
163
photo_db.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"""photo-pipeline: persistent SQLite fingerprint + Takeout tracking DB.
|
||||||
|
|
||||||
|
Two responsibilities:
|
||||||
|
1. image_hashes — permanent library identity (sha256 exact + phash/dhash near)
|
||||||
|
2. takeout_archives — shipment tracking for Takeout dumps (the "lost track" problem)
|
||||||
|
3. batches — which source batch fed which registration
|
||||||
|
|
||||||
|
Hashing is the foundation; this DB is where the hashes live and are queried.
|
||||||
|
Immich keeps its own hashes in Postgres as the in-library layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from imagehash import dhash, phash
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).parent / "photo_pipeline.db"
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS image_hashes (
|
||||||
|
sha256 TEXT PRIMARY KEY,
|
||||||
|
phash TEXT NOT NULL,
|
||||||
|
dhash TEXT NOT NULL,
|
||||||
|
file_size INTEGER,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
exif_date TEXT,
|
||||||
|
path TEXT,
|
||||||
|
source TEXT, -- takeout | archive | phone | immich
|
||||||
|
in_immich INTEGER DEFAULT 0,
|
||||||
|
added_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS takeout_archives (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
export_id TEXT,
|
||||||
|
archive_name TEXT UNIQUE,
|
||||||
|
sha256 TEXT, -- hash of the ARCHIVE file itself
|
||||||
|
size_bytes INTEGER,
|
||||||
|
created_at TEXT,
|
||||||
|
downloaded_at TEXT,
|
||||||
|
extracted_at TEXT,
|
||||||
|
extract_path TEXT,
|
||||||
|
file_count INTEGER,
|
||||||
|
status TEXT DEFAULT 'pending' -- pending|downloading|downloaded|extracted|registered
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS batches (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT,
|
||||||
|
archive_id INTEGER REFERENCES takeout_archives(id),
|
||||||
|
source TEXT,
|
||||||
|
started_at TEXT,
|
||||||
|
finished_at TEXT,
|
||||||
|
status TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_image_hashes_phash ON image_hashes(phash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_image_hashes_dhash ON image_hashes(dhash);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_image(path: Path, hash_size: int = 8):
|
||||||
|
"""Perceptual hashes for one image file."""
|
||||||
|
with Image.open(path) as im:
|
||||||
|
im = im.convert("RGB")
|
||||||
|
return {
|
||||||
|
"phash": str(phash(im, hash_size=hash_size)),
|
||||||
|
"dhash": str(dhash(im, hash_size=hash_size)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def register_image(conn, path: Path, source: str, exif_date: str | None = None):
|
||||||
|
"""Insert one image's hashes if not already present. Returns True if new."""
|
||||||
|
sha = sha256_file(path)
|
||||||
|
existing = conn.execute("SELECT 1 FROM image_hashes WHERE sha256=?", (sha,)).fetchone()
|
||||||
|
if existing:
|
||||||
|
return False
|
||||||
|
with Image.open(path) as im:
|
||||||
|
w, h = im.size
|
||||||
|
hx = hash_image(path)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO image_hashes (sha256, phash, dhash, file_size, width, height, exif_date, path, source) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
|
(sha, hx["phash"], hx["dhash"], path.stat().st_size, w, h, exif_date, str(path), source),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_exact_dup(conn, path: Path):
|
||||||
|
"""Return matching library path if this file's sha256 already exists."""
|
||||||
|
sha = sha256_file(path)
|
||||||
|
row = conn.execute("SELECT path, source FROM image_hashes WHERE sha256=?", (sha,)).fetchone()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def check_near_dup(conn, path: Path, hamming_threshold: int = 10):
|
||||||
|
"""Return nearest-matching library path if phash+dhash are close enough."""
|
||||||
|
hx = hash_image(path)
|
||||||
|
rows = conn.execute("SELECT phash, dhash, path, source FROM image_hashes").fetchall()
|
||||||
|
best, best_dist = None, None
|
||||||
|
for ph, dh, p, src in rows:
|
||||||
|
phd = hamming(ph, hx["phash"])
|
||||||
|
dhd = hamming(dh, hx["dhash"])
|
||||||
|
dist = min(phd, dhd)
|
||||||
|
if best_dist is None or dist < best_dist:
|
||||||
|
best, best_dist = (p, src), dist
|
||||||
|
if best_dist == 0:
|
||||||
|
break
|
||||||
|
if best and best_dist <= hamming_threshold:
|
||||||
|
return best, best_dist
|
||||||
|
return None, best_dist
|
||||||
|
|
||||||
|
|
||||||
|
def hamming(a: str, b: str) -> int:
|
||||||
|
return sum(1 for x, y in zip(a, b) if x != y)
|
||||||
|
|
||||||
|
|
||||||
|
def register_takeout_archive(conn, archive_name: str, path: Path) -> int:
|
||||||
|
"""Record a downloaded archive (or detect it's already known). Returns row id."""
|
||||||
|
sha = sha256_file(path)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM takeout_archives WHERE archive_name=?", (archive_name,)
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE takeout_archives SET sha256=?, size_bytes=?, downloaded_at=datetime('now'), status='downloaded' WHERE id=?",
|
||||||
|
(sha, path.stat().st_size, row[0]),
|
||||||
|
)
|
||||||
|
return row[0]
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO takeout_archives (archive_name, sha256, size_bytes, downloaded_at, status) "
|
||||||
|
"VALUES (?,?,?,datetime('now'),'downloaded')",
|
||||||
|
(archive_name, sha, path.stat().st_size),
|
||||||
|
)
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_db()
|
||||||
|
print(f"DB ready at {DB_PATH}")
|
||||||
115
photo_ingest.py
Normal file
115
photo_ingest.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""photo-pipeline: photo-ingest flow (v1).
|
||||||
|
|
||||||
|
Stage 1 of the pipeline: hash incoming images, check against the persistent
|
||||||
|
fingerprint DB (exact + near dupes), and register new ones.
|
||||||
|
|
||||||
|
Run via Prefect deployment on photo-pool (see prefect.yaml).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from prefect import flow, task
|
||||||
|
|
||||||
|
import photo_db as db
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def scan_directory(base_dir: str) -> list[str]:
|
||||||
|
"""Enumerate image files in a directory tree."""
|
||||||
|
exts = {".jpg", ".jpeg", ".png", ".heic", ".webp", ".gif", ".tif", ".tiff", ".bmp"}
|
||||||
|
root = Path(base_dir)
|
||||||
|
if not root.exists():
|
||||||
|
raise FileNotFoundError(f"{root} does not exist")
|
||||||
|
found = [
|
||||||
|
str(p)
|
||||||
|
for p in root.rglob("*")
|
||||||
|
if p.is_file() and p.suffix.lower() in exts
|
||||||
|
]
|
||||||
|
print(f"Found {len(found)} images under {root}")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def check_duplicates(image_paths: list[str]) -> dict:
|
||||||
|
"""Check each image against the fingerprint DB. Returns classification."""
|
||||||
|
db.init_db()
|
||||||
|
conn = db.get_db()
|
||||||
|
exact_dups = []
|
||||||
|
near_dups = []
|
||||||
|
new_images = []
|
||||||
|
for p_str in image_paths:
|
||||||
|
p = Path(p_str)
|
||||||
|
try:
|
||||||
|
match = db.check_exact_dup(conn, p)
|
||||||
|
if match:
|
||||||
|
exact_dups.append((p_str, match))
|
||||||
|
continue
|
||||||
|
near, dist = db.check_near_dup(conn, p)
|
||||||
|
if near:
|
||||||
|
near_dups.append((p_str, near, dist))
|
||||||
|
continue
|
||||||
|
new_images.append(p_str)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" SKIP {p.name}: {e}")
|
||||||
|
conn.close()
|
||||||
|
result = {
|
||||||
|
"total": len(image_paths),
|
||||||
|
"exact_dups": len(exact_dups),
|
||||||
|
"near_dups": len(near_dups),
|
||||||
|
"new": len(new_images),
|
||||||
|
"exact_dup_list": exact_dups[:50],
|
||||||
|
"near_dup_list": near_dups[:50],
|
||||||
|
"new_list": new_images,
|
||||||
|
}
|
||||||
|
print(
|
||||||
|
f"Check: {result['total']} total, "
|
||||||
|
f"{result['exact_dups']} exact dups, {result['near_dups']} near dups, "
|
||||||
|
f"{result['new']} new"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def register_new_images(image_paths: list[str], source: str) -> int:
|
||||||
|
"""Add hashes for confirmed-new images into the fingerprint DB."""
|
||||||
|
db.init_db()
|
||||||
|
conn = db.get_db()
|
||||||
|
registered = 0
|
||||||
|
for p_str in image_paths:
|
||||||
|
p = Path(p_str)
|
||||||
|
try:
|
||||||
|
if db.register_image(conn, p, source=source):
|
||||||
|
registered += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" FAIL register {p.name}: {e}")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"Registered {registered} new images (source={source})")
|
||||||
|
return registered
|
||||||
|
|
||||||
|
|
||||||
|
@flow(name="photo-ingest")
|
||||||
|
def photo_ingest(base_dir: str, source: str = "takeout", register: bool = True):
|
||||||
|
"""Hash + dedup-check a folder against the persistent library."""
|
||||||
|
images = scan_directory(base_dir)
|
||||||
|
if not images:
|
||||||
|
print("No images found — nothing to do.")
|
||||||
|
return {"total": 0}
|
||||||
|
|
||||||
|
result = check_duplicates(images)
|
||||||
|
|
||||||
|
if register and result["new_list"]:
|
||||||
|
n = register_new_images(result["new_list"], source=source)
|
||||||
|
result["registered"] = n
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Local run (no deployment)
|
||||||
|
import sys
|
||||||
|
|
||||||
|
d = sys.argv[1] if len(sys.argv) > 1 else "/tmp/sample"
|
||||||
|
r = photo_ingest(d)
|
||||||
|
print(r)
|
||||||
18
photo_pipeline_hello.py
Normal file
18
photo_pipeline_hello.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""photo-pipeline: first real flow (skeleton).
|
||||||
|
|
||||||
|
Prefect 3.8 local pattern: flow.serve() registers the deployment and
|
||||||
|
runs scheduled work. For the pilot this is the supported, storage-free path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from prefect import flow
|
||||||
|
|
||||||
|
|
||||||
|
@flow(name="photo-pipeline-hello")
|
||||||
|
def hello_pipeline():
|
||||||
|
print("photo-pipeline flow alive on .13")
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# serve() keeps this process alive, polls for scheduled runs
|
||||||
|
hello_pipeline.serve(name="hello", cron="*/5 * * * *", tags=["photo-pipeline"])
|
||||||
59
prefect.yaml
Normal file
59
prefect.yaml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
# Prefect project config for photo-pipeline
|
||||||
|
name: photo-pipeline
|
||||||
|
prefect-version: 3.8.1
|
||||||
|
|
||||||
|
build: null
|
||||||
|
push: null
|
||||||
|
|
||||||
|
pull:
|
||||||
|
- prefect.deployments.steps.set_working_directory:
|
||||||
|
directory: /home/sam/photo-pipeline
|
||||||
|
|
||||||
|
deployments:
|
||||||
|
- name: hello
|
||||||
|
version: null
|
||||||
|
tags: [photo-pipeline]
|
||||||
|
description: "Photo pipeline hello flow"
|
||||||
|
schedule:
|
||||||
|
cron: "*/5 * * * *"
|
||||||
|
timezone: "Australia/Melbourne"
|
||||||
|
flow_name: null
|
||||||
|
entrypoint: photo_pipeline_hello.py:hello_pipeline
|
||||||
|
parameters: {}
|
||||||
|
work_pool:
|
||||||
|
name: photo-pool
|
||||||
|
work_queue_name: null
|
||||||
|
job_variables: {}
|
||||||
|
|
||||||
|
- name: ingest
|
||||||
|
version: null
|
||||||
|
tags: [photo-pipeline]
|
||||||
|
description: "Hash + dedup-check a folder against the persistent fingerprint DB"
|
||||||
|
schedule: null
|
||||||
|
flow_name: null
|
||||||
|
entrypoint: photo_ingest.py:photo_ingest
|
||||||
|
parameters:
|
||||||
|
base_dir: /mnt/data/takeout
|
||||||
|
source: takeout
|
||||||
|
register: true
|
||||||
|
work_pool:
|
||||||
|
name: photo-pool
|
||||||
|
work_queue_name: null
|
||||||
|
job_variables: {}
|
||||||
|
|
||||||
|
- name: fetch
|
||||||
|
version: null
|
||||||
|
tags: [photo-pipeline]
|
||||||
|
description: "Download + track + extract Takeout archives (resume-capable)"
|
||||||
|
schedule: null
|
||||||
|
flow_name: null
|
||||||
|
entrypoint: takeout_fetch.py:takeout_fetch
|
||||||
|
parameters:
|
||||||
|
urls: []
|
||||||
|
manifest: null
|
||||||
|
export_id: null
|
||||||
|
work_pool:
|
||||||
|
name: photo-pool
|
||||||
|
work_queue_name: null
|
||||||
|
job_variables: {}
|
||||||
10
run-prefect.sh
Executable file
10
run-prefect.sh
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# NixOS venv wrapper for the prefect binary (same libs as run-python.sh)
|
||||||
|
set -euo pipefail
|
||||||
|
NIX_LIBS=(
|
||||||
|
/nix/store/0iv8glcslgfcgn371lbjr5jjw5a6cqir-gcc-15.3.0-lib/lib
|
||||||
|
/nix/store/l7xwm1f6f3zj2x8jwdbi8gdyfbx07sh7-zlib-1.3.1/lib
|
||||||
|
)
|
||||||
|
EXISTING="${LD_LIBRARY_PATH:-}"
|
||||||
|
export LD_LIBRARY_PATH="$(IFS=:; echo "${NIX_LIBS[*]}"):${EXISTING}"
|
||||||
|
exec "$HOME/photo-pipeline/.venv/bin/prefect" "$@"
|
||||||
15
run-python.sh
Executable file
15
run-python.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# NixOS venv wrapper: exposes needed system libs (libstdc++, zlib, ...) to the
|
||||||
|
# photo-pipeline venv. Edit NIX_LIBS as new deps (e.g. opencv) require more.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NIX_LIBS=(
|
||||||
|
/nix/store/0iv8glcslgfcgn371lbjr5jjw5a6cqir-gcc-15.3.0-lib/lib
|
||||||
|
/nix/store/l7xwm1f6f3zj2x8jwdbi8gdyfbx07sh7-zlib-1.3.1/lib
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge with any pre-existing LD_LIBRARY_PATH
|
||||||
|
EXISTING="${LD_LIBRARY_PATH:-}"
|
||||||
|
export LD_LIBRARY_PATH="$(IFS=:; echo "${NIX_LIBS[*]}"):${EXISTING}"
|
||||||
|
|
||||||
|
exec "$HOME/photo-pipeline/.venv/bin/python" "$@"
|
||||||
170
takeout_fetch.py
Normal file
170
takeout_fetch.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""photo-pipeline: Takeout download + extract flow (v2).
|
||||||
|
|
||||||
|
Completes the dump-tracking loop:
|
||||||
|
1. Download Takeout archives (resume-capable via aria2c)
|
||||||
|
2. Verify archive sha256 against takeout_archives (no re-download of known archives)
|
||||||
|
3. Extract to /mnt/data/takeout/<export>/
|
||||||
|
4. Update status in takeout_archives
|
||||||
|
|
||||||
|
Takeout gives each export a set of archive URLs (the "Download" page). Save them
|
||||||
|
to a manifest file (one URL per line) or pass a list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from prefect import flow, task
|
||||||
|
|
||||||
|
import photo_db as db
|
||||||
|
|
||||||
|
STAGING = Path("/mnt/data/takeout")
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def download_archive(url: str, dest_dir: Path = STAGING) -> Path:
|
||||||
|
"""Download one Takeout archive with aria2c (resume-capable, 4 connections).
|
||||||
|
|
||||||
|
Returns the local archive path. Skips if archive sha256 already known+downloaded.
|
||||||
|
"""
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
archive_name = url.split("/")[-1].split("?")[0]
|
||||||
|
if not archive_name:
|
||||||
|
archive_name = f"takeout-{abs(hash(url))}.zip"
|
||||||
|
dest = dest_dir / archive_name
|
||||||
|
|
||||||
|
# Skip if we already have it downloaded (by name)
|
||||||
|
if dest.exists() and dest.stat().st_size > 0:
|
||||||
|
print(f" already on disk: {archive_name} ({dest.stat().st_size} bytes) — verifying sha256")
|
||||||
|
sha = db.sha256_file(dest)
|
||||||
|
conn = db.get_db()
|
||||||
|
known = conn.execute(
|
||||||
|
"SELECT id FROM takeout_archives WHERE archive_name=? AND sha256=?", (archive_name, sha)
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if known:
|
||||||
|
print(f" verified — already tracked (id={known[0]}). Skipping download.")
|
||||||
|
return dest
|
||||||
|
print(f" on disk but untracked sha — re-downloading to be safe")
|
||||||
|
|
||||||
|
print(f"Downloading {archive_name} → {dest}")
|
||||||
|
cmd = [
|
||||||
|
"aria2c",
|
||||||
|
"-x", "4", "-s", "4", # 4 connections
|
||||||
|
"-c", # continue/resume
|
||||||
|
"--auto-file-renaming=false",
|
||||||
|
"--allow-overwrite=false",
|
||||||
|
"-d", str(dest_dir),
|
||||||
|
"-o", archive_name,
|
||||||
|
url,
|
||||||
|
]
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"aria2c failed for {archive_name}:\n{r.stderr[-1000:]}")
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def track_archive(archive_path: Path, export_id: str | None = None) -> int:
|
||||||
|
"""Record the downloaded archive in takeout_archives (or update it)."""
|
||||||
|
db.init_db()
|
||||||
|
conn = db.get_db()
|
||||||
|
archive_id = db.register_takeout_archive(conn, archive_path.name, archive_path)
|
||||||
|
if export_id:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE takeout_archives SET export_id=? WHERE id=?", (export_id, archive_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"Tracked archive id={archive_id} ({archive_path.name}, {archive_path.stat().st_size} bytes)")
|
||||||
|
return archive_id
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def extract_archive(archive_path: Path, export_dir: str | None = None) -> Path:
|
||||||
|
"""Extract a Takeout archive to /mnt/data/takeout/<archive_stem>/.
|
||||||
|
|
||||||
|
Handles .zip (unzip or python zipfile) and .tgz/.tar.gz (tar).
|
||||||
|
"""
|
||||||
|
if export_dir:
|
||||||
|
out_root = STAGING / export_dir
|
||||||
|
else:
|
||||||
|
out_root = STAGING / archive_path.stem.replace(".tar", "")
|
||||||
|
out_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if archive_path.suffix == ".zip" or archive_path.name.endswith(".zip"):
|
||||||
|
# Try unzip first; fall back to python zipfile (no external dep)
|
||||||
|
if shutil.which("unzip"):
|
||||||
|
r = subprocess.run(
|
||||||
|
["unzip", "-o", "-q", str(archive_path), "-d", str(out_root)],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"unzip failed: {r.stderr[-500:]}")
|
||||||
|
else:
|
||||||
|
import zipfile
|
||||||
|
with zipfile.ZipFile(archive_path) as zf:
|
||||||
|
zf.extractall(out_root)
|
||||||
|
elif archive_path.name.endswith((".tgz", ".tar.gz", ".tar")):
|
||||||
|
r = subprocess.run(
|
||||||
|
["tar", "-xzf", str(archive_path), "-C", str(out_root)],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"tar failed: {r.stderr[-500:]}")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported archive type: {archive_path.name}")
|
||||||
|
|
||||||
|
# Count extracted files
|
||||||
|
n = sum(1 for _ in out_root.rglob("*") if _.is_file())
|
||||||
|
db.init_db()
|
||||||
|
conn = db.get_db()
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE takeout_archives SET extracted_at=datetime('now'), extract_path=?, file_count=?, status='extracted' "
|
||||||
|
"WHERE archive_name=?",
|
||||||
|
(str(out_root), n, archive_path.name),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"Extracted {n} files → {out_root}")
|
||||||
|
return out_root
|
||||||
|
|
||||||
|
|
||||||
|
@flow(name="takeout-fetch")
|
||||||
|
def takeout_fetch(
|
||||||
|
urls: list[str] | None = None,
|
||||||
|
manifest: str | None = None,
|
||||||
|
export_id: str | None = None,
|
||||||
|
):
|
||||||
|
"""Download + track + extract a set of Takeout archives.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
urls: list of archive download URLs (from takeout.google.com Download page)
|
||||||
|
manifest: path to a text file with one URL per line (alternative to urls)
|
||||||
|
export_id: label for this export (e.g. "photos-2026-08")
|
||||||
|
"""
|
||||||
|
if manifest:
|
||||||
|
mp = Path(manifest)
|
||||||
|
if not mp.exists():
|
||||||
|
raise FileNotFoundError(f"manifest not found: {mp}")
|
||||||
|
urls = [l.strip() for l in mp.read_text().splitlines() if l.strip().startswith("http")]
|
||||||
|
if not urls:
|
||||||
|
raise ValueError("provide urls or a manifest file")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for url in urls:
|
||||||
|
path = download_archive(url)
|
||||||
|
archive_id = track_archive(path, export_id=export_id)
|
||||||
|
out = extract_archive(path, export_dir=export_id)
|
||||||
|
results.append({"archive": path.name, "id": archive_id, "extracted_to": str(out)})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("usage: python takeout_fetch.py <manifest-file> [export_id]")
|
||||||
|
sys.exit(1)
|
||||||
|
man = sys.argv[1]
|
||||||
|
eid = sys.argv[2] if len(sys.argv) > 2 else None
|
||||||
|
takeout_fetch(manifest=man, export_id=eid)
|
||||||
Reference in New Issue
Block a user