gphototool 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ data/
8
+ *.db
9
+ .env
@@ -0,0 +1,47 @@
1
+ # GPhotoTool — Architecture
2
+
3
+ ## What it does
4
+ Upload any file → gp_disguise wraps as MP4 → gpmc uploads via Pixel XL spoof → unlimited storage.
5
+
6
+ ## Stack
7
+ - **Backend**: FastAPI + SQLite (task persistence) + threading (workers)
8
+ - **Frontend**: Single HTML + Tailwind CDN + vanilla JS
9
+ - **Deploy**: nginx (reverse proxy, rate limit, large uploads) + systemd
10
+
11
+ ## Flow
12
+ ```
13
+ Browser → POST /api/upload (file + auth_data)
14
+ → save to /tmp/gpt_{task_id}/
15
+ → background thread:
16
+ 1. gpd hide -t video → output.mp4
17
+ 2. gpmc output.mp4 → media_key
18
+ → poll GET /api/status/{task_id}
19
+ → done: link to Google Photos
20
+ ```
21
+
22
+ ## Files
23
+ ```
24
+ gphotos-tool/
25
+ ├── app.py # FastAPI backend (API + worker + cleanup)
26
+ ├── static/
27
+ │ └── index.html # SPA frontend
28
+ ├── deploy/
29
+ │ ├── nginx.conf # nginx reverse proxy config
30
+ │ └── gphotos-tool.service # systemd unit
31
+ ├── run.sh # Dev launcher
32
+ └── README.md
33
+ ```
34
+
35
+ ## Security
36
+ - Auth validated client-side only (user's token, not stored)
37
+ - File type whitelist (no .sh, .bat, executables with code)
38
+ - Max 10 GB per file
39
+ - Filenames sanitized (path traversal blocked)
40
+ - Rate limit: 10 uploads/min via nginx
41
+ - No auth logging (token never in logs)
42
+
43
+ ## Limits (ponytail: ship MVP, tune later)
44
+ - Concurrent uploads: 3 (semaphore)
45
+ - Task retention: 6 hours (cleanup thread)
46
+ - No database migrations needed (single table)
47
+ - No WebSocket (polling is fine for upload progress)
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.5
2
+ Name: gphototool
3
+ Version: 0.1.0
4
+ Summary: Convert any file to video → unlimited Google Photos upload
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: fastapi
8
+ Requires-Dist: gpmc
9
+ Requires-Dist: python-multipart
10
+ Requires-Dist: uvicorn[standard]
11
+ Provides-Extra: disguise
12
+ Requires-Dist: gp-disguise; extra == 'disguise'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # GPhotoTool
16
+
17
+ Convert any file to video → upload **unlimited** to Google Photos.
18
+
19
+ No storage quota. No server. Runs 100% locally on your machine.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install gphototool
25
+ pip install gp_disguise@git+https://github.com/xob0t/gp_disguise.git
26
+ ```
27
+
28
+ Or one-liner:
29
+ ```bash
30
+ pip install gphototool && pip install gp_disguise@git+https://github.com/xob0t/gp_disguise.git
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```bash
36
+ gphototool
37
+ ```
38
+
39
+ Opens browser to `http://localhost:8800`. Drag, drop, done.
40
+
41
+ ## How it works
42
+
43
+ 1. Drop any file (ZIP, PDF, APK, ISO, DOC, MP3, anything)
44
+ 2. `gp_disguise` wraps it as an MP4 video
45
+ 3. `gpmc` uploads via Pixel XL spoof API — **zero quota charged**
46
+ 4. File stays on your machine the whole time
47
+
48
+ ## Auth token (one-time setup)
49
+
50
+ **Easiest:** Open the expandable help in the web UI — it has step-by-step instructions.
51
+
52
+ **Or:** Copy the AI-agent prompt from [docs/AUTH_TOKEN.md](docs/AUTH_TOKEN.md) and paste it to Claude/ChatGPT. It will guide you through capturing the token.
53
+
54
+ **Manual:**
55
+ ```bash
56
+ adb logcat -c && adb logcat | grep auth%2Fphotos
57
+ # Open Morphe Photos → tap profile → sign in
58
+ # Copy the androidId=...&Token=... line
59
+ # Save: echo "TOKEN" > ~/.config/gphototool/auth_data && chmod 600 ~/.config/gphototool/auth_data
60
+ ```
61
+
62
+ Token expires in ~24 hours. Re-capture when upload fails with auth error.
63
+
64
+ ## Requirements
65
+
66
+ - Python 3.11+
67
+ - `gpd` (gp_disguise) — installed automatically
68
+ - `gpmc` — installed automatically
69
+
70
+ ## Architecture
71
+
72
+ ```
73
+ Browser (localhost:8800)
74
+ ↕ HTTP
75
+ FastAPI server (local)
76
+ → gpd: file → MP4
77
+ → gpmc: MP4 → Google Photos (unlimited)
78
+ ```
79
+
80
+ Files never leave your machine. Zero server costs.
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,70 @@
1
+ # GPhotoTool
2
+
3
+ Convert any file to video → upload **unlimited** to Google Photos.
4
+
5
+ No storage quota. No server. Runs 100% locally on your machine.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install gphototool
11
+ pip install gp_disguise@git+https://github.com/xob0t/gp_disguise.git
12
+ ```
13
+
14
+ Or one-liner:
15
+ ```bash
16
+ pip install gphototool && pip install gp_disguise@git+https://github.com/xob0t/gp_disguise.git
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```bash
22
+ gphototool
23
+ ```
24
+
25
+ Opens browser to `http://localhost:8800`. Drag, drop, done.
26
+
27
+ ## How it works
28
+
29
+ 1. Drop any file (ZIP, PDF, APK, ISO, DOC, MP3, anything)
30
+ 2. `gp_disguise` wraps it as an MP4 video
31
+ 3. `gpmc` uploads via Pixel XL spoof API — **zero quota charged**
32
+ 4. File stays on your machine the whole time
33
+
34
+ ## Auth token (one-time setup)
35
+
36
+ **Easiest:** Open the expandable help in the web UI — it has step-by-step instructions.
37
+
38
+ **Or:** Copy the AI-agent prompt from [docs/AUTH_TOKEN.md](docs/AUTH_TOKEN.md) and paste it to Claude/ChatGPT. It will guide you through capturing the token.
39
+
40
+ **Manual:**
41
+ ```bash
42
+ adb logcat -c && adb logcat | grep auth%2Fphotos
43
+ # Open Morphe Photos → tap profile → sign in
44
+ # Copy the androidId=...&Token=... line
45
+ # Save: echo "TOKEN" > ~/.config/gphototool/auth_data && chmod 600 ~/.config/gphototool/auth_data
46
+ ```
47
+
48
+ Token expires in ~24 hours. Re-capture when upload fails with auth error.
49
+
50
+ ## Requirements
51
+
52
+ - Python 3.11+
53
+ - `gpd` (gp_disguise) — installed automatically
54
+ - `gpmc` — installed automatically
55
+
56
+ ## Architecture
57
+
58
+ ```
59
+ Browser (localhost:8800)
60
+ ↕ HTTP
61
+ FastAPI server (local)
62
+ → gpd: file → MP4
63
+ → gpmc: MP4 → Google Photos (unlimited)
64
+ ```
65
+
66
+ Files never leave your machine. Zero server costs.
67
+
68
+ ## License
69
+
70
+ MIT
@@ -0,0 +1,31 @@
1
+ # GPhotoTool — Specification
2
+
3
+ ## What
4
+ CLI tool + browser UI. User runs locally. Converts any file to video → uploads unlimited to Google Photos via Pixel XL spoof API.
5
+
6
+ ## Who
7
+ - Primary: LO (you) — daily use for unlimited backup
8
+ - Secondary: anyone who wants unlimited GPhotos storage
9
+
10
+ ## Requirements
11
+ 1. `pip install gphototool` → single command install
12
+ 2. `gphototool` → opens browser, starts local server
13
+ 3. Drag-and-drop files → disguised as video → uploaded unlimited
14
+ 4. Auth token: one-time paste from `adb logcat` (ReVanced/Morphe Photos)
15
+ 5. Progress tracking in browser
16
+ 6. Works on Linux, macOS, Windows
17
+
18
+ ## Non-requirements (ponytail: YAGNI)
19
+ - No user accounts / auth system
20
+ - No cloud hosting
21
+ - No database
22
+ - No file sharing between users
23
+ - No batch scheduling
24
+ - No Docker
25
+
26
+ ## Constraints
27
+ - Files never leave user's machine (privacy + zero server cost)
28
+ - Max file: 10 GB (Google Photos limit for video)
29
+ - Conversion: gpd (Python, wraps file in MP4 container)
30
+ - Upload: gpmc (Python, hits Google mobile API with Pixel XL spoof)
31
+ - Both tools are Python — bundle in same package
@@ -0,0 +1,104 @@
1
+ # GPhotoTool — Architecture
2
+
3
+ ## Components
4
+
5
+ ```
6
+ ┌─────────────────────────────────────────────┐
7
+ │ User's Machine │
8
+ │ │
9
+ │ ┌──────────────┐ ┌───────────────────┐ │
10
+ │ │ Browser │◄──►│ Local Server │ │
11
+ │ │ (index.html) │ │ (app.py :8800) │ │
12
+ │ │ │ │ │ │
13
+ │ │ - Upload UI │ │ - Accept file │ │
14
+ │ │ - Progress │ │ - Run gpd │ │
15
+ │ │ - Results │ │ - Run gpmc │ │
16
+ │ └──────────────┘ │ - Cleanup │ │
17
+ │ └───────────────────┘ │
18
+ │ │
19
+ │ ┌──────────────┐ ┌───────────────────┐ │
20
+ │ │ gpd │ │ gpmc │ │
21
+ │ │ (disguise) │ │ (upload) │ │
22
+ │ └──────────────┘ └───────────────────┘ │
23
+ └─────────────────────────────────────────────┘
24
+ ```
25
+
26
+ ## Data Flow
27
+
28
+ ```
29
+ 1. User: gphototool
30
+ └─ Starts server on :8800
31
+ └─ Opens browser to localhost:8800
32
+
33
+ 2. User: drops file in browser
34
+ └─ POST /api/upload (file + auth_data)
35
+ └─ Server saves to /tmp/gpt_{id}/
36
+
37
+ 3. Server (background thread):
38
+ └─ Step 1: gpd hide -t video -o out.mp4 file
39
+ └─ Step 2: gpmc out.mp4 --progress
40
+ └─ Step 3: return media_key
41
+
42
+ 4. Browser: polls GET /api/status/{id}
43
+ └─ Shows progress: queued → converting → uploading → done
44
+
45
+ 5. Done:
46
+ └─ Returns photos_url for Google Photos
47
+ └─ Cleans up temp files
48
+ ```
49
+
50
+ ## File Structure
51
+
52
+ ```
53
+ gphototool/
54
+ ├── pyproject.toml # Package config (pip install)
55
+ ├── src/
56
+ │ └── gphototool/
57
+ │ ├── __init__.py # Version
58
+ │ ├── __main__.py # Entry point: python -m gphototool
59
+ │ ├── cli.py # CLI: gphototool command
60
+ │ ├── server.py # FastAPI server
61
+ │ ├── worker.py # Background conversion/upload
62
+ │ └── static/
63
+ │ └── index.html # Web UI
64
+ ├── docs/
65
+ │ ├── 01-specification.md
66
+ │ └── 02-architecture.md
67
+ └── README.md
68
+ ```
69
+
70
+ ## API
71
+
72
+ | Method | Path | What |
73
+ |--------|------|------|
74
+ | POST | /api/upload | File + auth_data → task_id |
75
+ | GET | /api/status/{id} | Task status + progress |
76
+ | GET | /api/health | Server health check |
77
+ | GET | / | Web UI |
78
+
79
+ ## Dependencies
80
+
81
+ | Package | Purpose | Size |
82
+ |---------|---------|------|
83
+ | fastapi | HTTP server | ~2 MB |
84
+ | uvicorn | ASGI server | ~1 MB |
85
+ | python-multipart | File uploads | ~100 KB |
86
+ | gp_disguise | File → video disguise | ~100 KB |
87
+ | gpmc | Unlimited GPhotos upload | ~50 KB |
88
+
89
+ All bundled in one pip package. No system deps except Python 3.10+.
90
+
91
+ ## Security Model
92
+
93
+ - No auth system (single user, local only)
94
+ - Auth token: user's Google API token, stored in memory only, never logged
95
+ - CORS: localhost only
96
+ - File type whitelist: known safe extensions
97
+ - Max file: 10 GB
98
+ - Temp files: auto-cleaned after upload
99
+
100
+ ## Deployment
101
+
102
+ - PyPI: `pip install gphototool`
103
+ - GitHub: source + releases
104
+ - No server hosting needed
@@ -0,0 +1,80 @@
1
+ # GPhotoTool — Pseudocode
2
+
3
+ ## cli.py
4
+ ```python
5
+ def main():
6
+ """gphototool entry point"""
7
+ port = find_free_port(8800)
8
+ start_browser(f"http://localhost:{port}")
9
+ uvicorn.run(server.app, host="127.0.0.1", port=port)
10
+ ```
11
+
12
+ ## server.py
13
+ ```python
14
+ app = FastAPI()
15
+ tasks = {} # in-memory (single user, no DB needed)
16
+
17
+ @app.post("/api/upload")
18
+ async def upload(file, auth_data):
19
+ validate_auth(auth_data) # must start with androidId=
20
+ validate_file(file) # check extension + size
21
+ task_id = save_file(file) # save to /tmp/gpt_{id}/
22
+ spawn_thread(process, task_id) # background: disguise + upload
23
+ return {task_id, filename, size}
24
+
25
+ @app.get("/api/status/{id}")
26
+ def status(id):
27
+ return tasks[id] # status, step, media_key, error, etc.
28
+
29
+ @app.get("/api/health")
30
+ def health():
31
+ return {gpmc: bool, gpd: bool}
32
+ ```
33
+
34
+ ## worker.py
35
+ ```python
36
+ def process(task_id, src, auth):
37
+ # Step 1: Disguise
38
+ update(task_id, "disguising")
39
+ run(["gpd", "hide", "-t", "video", "-o", out, src])
40
+
41
+ # Step 2: Upload
42
+ update(task_id, "uploading")
43
+ env["GP_AUTH_DATA"] = auth
44
+ run(["gpmc", out, "--progress", "--timeout", "7200"])
45
+
46
+ # Step 3: Parse result
47
+ media_key = parse_gpmc_output(output)
48
+ update(task_id, "done", media_key=media_key)
49
+
50
+ # Cleanup
51
+ shutil.rmtree(tmp_dir)
52
+ ```
53
+
54
+ ## index.html
55
+ ```
56
+ - Auth token input
57
+ - Drop zone (drag-and-drop)
58
+ - File info + Upload button
59
+ - Progress bar (polls /api/status every 2s)
60
+ - Result card (Photos link + copy key)
61
+ - Upload history (session only)
62
+ ```
63
+
64
+ ## Key Decisions
65
+
66
+ 1. **No DB**: Single-user local tool. In-memory dict is fine. Tasks lost on restart = acceptable.
67
+
68
+ 2. **No WebSocket**: Polling every 2s is simple and works. Progress updates are coarse (queued → disguising → uploading → done) so fine-grained updates unnecessary.
69
+
70
+ 3. **gpd/gpmc as subprocess**: Not importing Python modules directly. Subprocess gives:
71
+ - Clean process isolation
72
+ - No import conflicts
73
+ - Works with uv tool installs
74
+ - Output capture for progress parsing
75
+
76
+ 4. **Port auto-discovery**: Try 8800, fallback to 8801-8810 if occupied.
77
+
78
+ 5. **Browser auto-open**: `webbrowser.open()` on start. No manual URL copying.
79
+
80
+ 6. **Temp files in system tmpdir**: `/tmp/gpt_*` — OS cleans on reboot. We also clean after upload.
@@ -0,0 +1,80 @@
1
+ # How to Get Your Auth Token
2
+
3
+ ## Quick version (for AI agents)
4
+
5
+ Copy everything in the code block below and paste it to Claude, ChatGPT, or any AI agent. It will guide you through capturing the token.
6
+
7
+ ```
8
+ I need to capture a Google Photos auth token for gphototool (unlimited upload tool).
9
+
10
+ My setup:
11
+ - Android phone with ReVanced or Morphe Google Photos installed
12
+ - ReVanced GMS (microG) running with my Google account logged in
13
+ - USB cable connected to PC, ADB enabled
14
+
15
+ Steps I need help with:
16
+ 1. Verify ADB connection: `adb devices`
17
+ 2. Clear logcat: `adb logcat -c`
18
+ 3. Tell me to open Morphe/ReVanced Google Photos and tap my profile to sign in
19
+ 4. Monitor logcat for the auth line: `adb logcat | grep auth%2Fphotos.native`
20
+ 5. When the line starting with `androidId=...` appears, extract and save it
21
+ 6. Verify the token works by checking it has: androidId, app, Token fields
22
+ 7. Save it to ~/.config/gphototool/auth_data (chmod 600)
23
+
24
+ Do NOT display the full token in chat — just confirm it was captured successfully and show the first 30 chars.
25
+ ```
26
+
27
+ ## Manual steps
28
+
29
+ ### Prerequisites
30
+ - Android phone with **ReVanced** or **Morphe** Google Photos (patched APK)
31
+ - **ReVanced GMS** (microG) installed and running
32
+ - Your Google account logged into microG
33
+ - USB cable + ADB on PC
34
+
35
+ ### Capture the token
36
+
37
+ 1. **Connect phone:**
38
+ ```bash
39
+ adb devices
40
+ # Should show your device
41
+ ```
42
+
43
+ 2. **Start monitoring:**
44
+ ```bash
45
+ adb logcat -c # clear old logs
46
+ adb logcat | grep "auth%2Fphotos"
47
+ ```
48
+
49
+ 3. **On your phone:**
50
+ - Open **Morphe** or **ReVanced Google Photos**
51
+ - Tap the **profile icon** (top right)
52
+ - Tap your Google account to sign in
53
+ - Tap **Get started** if prompted
54
+
55
+ 4. **On your PC**, a line appears:
56
+ ```
57
+ D GmsHttpFormClient: androidId=367eac4a...&app=com.google.android.apps.photos&...&Token=aas_et%2FAKppIN...
58
+ ```
59
+
60
+ 5. **Copy the entire line** starting from `androidId=` to the end
61
+
62
+ 6. **Save it:**
63
+ ```bash
64
+ mkdir -p ~/.config/gphototool
65
+ # Paste the token between the quotes:
66
+ echo "PASTE_TOKEN_HERE" > ~/.config/gphototool/auth_data
67
+ chmod 600 ~/.config/gphototool/auth_data
68
+ ```
69
+
70
+ ### Token expires in ~24 hours
71
+ When uploads fail with auth error, repeat steps 2-6 to get a fresh token.
72
+
73
+ ## Troubleshooting
74
+
75
+ | Problem | Fix |
76
+ |---------|-----|
77
+ | No auth line in logcat | Make sure ReVanced/Morphe Photos is the patched APK, not stock |
78
+ | `401 Unauthorized` | Token expired — capture fresh one |
79
+ | `adb: unauthorised` | Accept USB debugging prompt on phone |
80
+ | No photos appear in app | Make sure microG has your Google account logged in |
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "gphototool"
7
+ version = "0.1.0"
8
+ description = "Convert any file to video → unlimited Google Photos upload"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ dependencies = [
13
+ "fastapi",
14
+ "uvicorn[standard]",
15
+ "python-multipart",
16
+ "gpmc",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ disguise = ["gp_disguise"]
21
+
22
+ [project.scripts]
23
+ gphototool = "gphototool.cli:main"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/gphototool"]
27
+
28
+ [tool.hatch.metadata]
29
+ allow-direct-references = true
@@ -0,0 +1,2 @@
1
+ """gphototool — unlimited Google Photos upload via file disguise."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ """Allow `python -m gphototool`"""
2
+ from gphototool.cli import main
3
+ main()
@@ -0,0 +1,48 @@
1
+ """CLI entry point: gphototool"""
2
+ import sys, socket, webbrowser, argparse
3
+ from pathlib import Path
4
+
5
+
6
+ def _free_port(start: int = 8800) -> int:
7
+ for p in range(start, start + 20):
8
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
9
+ try:
10
+ s.bind(("127.0.0.1", p))
11
+ return p
12
+ except OSError:
13
+ continue
14
+ print("error: no free port found (8800-8819)", file=sys.stderr)
15
+ sys.exit(1)
16
+
17
+
18
+ def main():
19
+ ap = argparse.ArgumentParser(prog="gphototool", description="Unlimited Google Photos upload")
20
+ ap.add_argument("--port", "-p", type=int, default=0, help="Port (default: auto 8800)")
21
+ ap.add_argument("--no-browser", action="store_true", help="Don't open browser")
22
+ args = ap.parse_args()
23
+
24
+ port = args.port or _free_port()
25
+ url = f"http://localhost:{port}"
26
+
27
+ # Check tools
28
+ import shutil
29
+ if not shutil.which("gpd"):
30
+ print("error: gpd not found — install gp_disguise: pip install gp_disguise", file=sys.stderr)
31
+ sys.exit(1)
32
+ if not shutil.which("gpmc"):
33
+ print("error: gpmc not found — install gpmc: pip install gpmc", file=sys.stderr)
34
+ sys.exit(1)
35
+
36
+ if not args.no_browser:
37
+ webbrowser.open(url)
38
+
39
+ print(f"gphototool running at {url}")
40
+ print("Press Ctrl+C to stop\n")
41
+
42
+ import uvicorn
43
+ from gphototool.server import app
44
+ uvicorn.run(app, host="127.0.0.1", port=port, log_level="info")
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main()
@@ -0,0 +1,199 @@
1
+ """FastAPI server — local only, single user."""
2
+ import uuid, time, hashlib, threading, json, logging
3
+ from pathlib import Path
4
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
5
+ from fastapi.responses import JSONResponse, FileResponse
6
+ from fastapi.staticfiles import StaticFiles
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+
9
+ from gphototool.worker import process_file
10
+
11
+ log = logging.getLogger("gphototool")
12
+
13
+ # ── Config ───────────────────────────────────────────────────────────────
14
+ MAX_SIZE = 10 * 1024 * 1024 * 1024 # 10 GB
15
+ AUTH_FILE = Path.home() / ".config" / "gphototool" / "auth_data"
16
+
17
+ EXT = {
18
+ ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", ".zst",
19
+ ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt",
20
+ ".txt", ".csv", ".json", ".xml", ".yaml", ".yml", ".toml",
21
+ ".exe", ".msi", ".deb", ".rpm", ".apk", ".ipa",
22
+ ".iso", ".img", ".bin", ".dat",
23
+ ".mp3", ".wav", ".flac", ".aac", ".ogg",
24
+ ".mp4", ".mkv", ".avi", ".mov", ".webm", # pre-disguised or direct upload
25
+ ".psd", ".ai", ".sketch",
26
+ ".py", ".js", ".ts", ".go", ".rs", ".c", ".cpp", ".java",
27
+ ".db", ".sqlite", ".sqlite3",
28
+ }
29
+
30
+ # ── State (single user, in-memory is fine) ──────────────────────────────
31
+ import tempfile
32
+ UPLOAD_DIR = Path(tempfile.mkdtemp(prefix="gpt_"))
33
+ tasks: dict[str, dict] = {}
34
+ lock = threading.Lock()
35
+
36
+
37
+ def _save(t: dict):
38
+ with lock:
39
+ tasks[t["id"]] = t
40
+
41
+
42
+ def _get(task_id: str) -> dict | None:
43
+ with lock:
44
+ return tasks.get(task_id)
45
+
46
+
47
+ # ── FastAPI ──────────────────────────────────────────────────────────────
48
+ app = FastAPI(title="GPhotoTool", docs_url=None, redoc_url=None)
49
+ app.add_middleware(
50
+ CORSMiddleware,
51
+ allow_origins=["http://localhost", "http://127.0.0.1"],
52
+ allow_methods=["*"],
53
+ allow_headers=["*"],
54
+ )
55
+
56
+
57
+ @app.exception_handler(Exception)
58
+ async def _err(req, exc):
59
+ log.error("%s %s — %s", req.method, req.url.path, exc)
60
+ return JSONResponse({"error": "internal error"}, status_code=500)
61
+
62
+
63
+ @app.post("/api/upload")
64
+ async def upload(
65
+ file: UploadFile = File(...),
66
+ auth_data: str = Form(""),
67
+ mode: str = Form("both"),
68
+ ):
69
+ """mode: disguise | upload | both"""
70
+ if mode not in ("disguise", "upload", "both"):
71
+ raise HTTPException(400, "mode must be: disguise, upload, or both")
72
+
73
+ # Auth required for upload modes
74
+ if mode in ("upload", "both"):
75
+ if not auth_data:
76
+ raise HTTPException(400, "Auth token required for upload")
77
+ if not auth_data.startswith("androidId="):
78
+ raise HTTPException(400, "Invalid auth_data — must start with androidId=")
79
+ if "&Token=" not in auth_data:
80
+ raise HTTPException(400, "Invalid auth_data — missing Token field. Capture fresh token from logcat.")
81
+ if len(auth_data) > 5000:
82
+ raise HTTPException(400, "auth_data too long")
83
+
84
+ # Validate file
85
+ fname = Path(file.filename or "upload").name.strip(". ") or "upload"
86
+ if len(fname) > 200:
87
+ fname = fname[:190] + Path(fname).suffix
88
+ ext = Path(fname).suffix.lower()
89
+ if ext not in EXT:
90
+ raise HTTPException(400, f"Unsupported: {ext}")
91
+
92
+ # Save
93
+ task_id = uuid.uuid4().hex[:12]
94
+ task_dir = UPLOAD_DIR / task_id
95
+ task_dir.mkdir(exist_ok=True)
96
+ src = task_dir / fname
97
+
98
+ size = 0
99
+ try:
100
+ with open(src, "wb") as f:
101
+ while chunk := await file.read(1024 * 1024):
102
+ size += len(chunk)
103
+ if size > MAX_SIZE:
104
+ src.unlink(missing_ok=True)
105
+ raise HTTPException(413, "Max 10 GB")
106
+ f.write(chunk)
107
+ except HTTPException:
108
+ raise
109
+ except Exception as e:
110
+ src.unlink(missing_ok=True)
111
+ raise HTTPException(500, str(e))
112
+
113
+ if size == 0:
114
+ src.unlink(missing_ok=True)
115
+ raise HTTPException(400, "Empty file")
116
+
117
+ # Create task
118
+ t = {
119
+ "id": task_id, "filename": fname, "size": size,
120
+ "status": "queued", "step": "Queued", "mode": mode,
121
+ "media_key": None, "photos_url": None, "error": None,
122
+ "disguised_size": None, "elapsed": None,
123
+ "converted_file": None,
124
+ "created": time.time(),
125
+ }
126
+ _save(t)
127
+ threading.Thread(target=process_file, args=(t, src, auth_data, UPLOAD_DIR, mode), daemon=True).start()
128
+ log.info("queued %s (%d bytes, mode=%s)", fname, size, mode)
129
+
130
+ return {"task_id": task_id, "filename": fname, "size": size, "mode": mode}
131
+
132
+
133
+ @app.get("/api/status/{task_id}")
134
+ def status(task_id: str):
135
+ t = _get(task_id)
136
+ if not t:
137
+ raise HTTPException(404)
138
+ d = {k: t[k] for k in (
139
+ "status", "step", "filename", "size", "mode",
140
+ "media_key", "photos_url", "error",
141
+ "disguised_size", "elapsed",
142
+ )}
143
+ # Add download link for disguise mode
144
+ if t["mode"] == "disguise" and t["status"] == "done":
145
+ d["download_url"] = f"/api/download/{task_id}"
146
+ return d
147
+
148
+
149
+ @app.get("/api/download/{task_id}")
150
+ def download(task_id: str):
151
+ """Download converted MP4 file (disguise mode)."""
152
+ t = _get(task_id)
153
+ if not t:
154
+ raise HTTPException(404)
155
+ if t["mode"] != "disguise" or t["status"] != "done":
156
+ raise HTTPException(400, "Download only available for disguise mode after completion")
157
+
158
+ # Find the MP4 file
159
+ task_dir = UPLOAD_DIR / task_id
160
+ mp4_files = list(task_dir.glob("*.mp4"))
161
+ if not mp4_files:
162
+ raise HTTPException(404, "Converted file not found — may have been cleaned up")
163
+
164
+ mp4 = mp4_files[0]
165
+ download_name = f"{Path(t['filename']).stem}.mp4"
166
+ return FileResponse(mp4, filename=download_name, media_type="video/mp4")
167
+
168
+
169
+ @app.get("/api/health")
170
+ def health():
171
+ import shutil
172
+ return {"gpmc": bool(shutil.which("gpmc")), "gpd": bool(shutil.which("gpd"))}
173
+
174
+
175
+ @app.get("/api/auth")
176
+ def get_auth():
177
+ """Return saved auth token (first 30 chars) or empty if none."""
178
+ if AUTH_FILE.exists():
179
+ token = AUTH_FILE.read_text().strip()
180
+ if token.startswith("androidId="):
181
+ return {"saved": True, "preview": token[:30] + "..."}
182
+ return {"saved": False, "preview": ""}
183
+
184
+
185
+ @app.post("/api/auth")
186
+ def save_auth(auth_data: str = Form(...)):
187
+ """Save auth token to disk for next session."""
188
+ if not auth_data.startswith("androidId=") or "&Token=" not in auth_data:
189
+ raise HTTPException(400, "Invalid auth token")
190
+ AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
191
+ AUTH_FILE.write_text(auth_data)
192
+ AUTH_FILE.chmod(0o600)
193
+ return {"saved": True}
194
+
195
+
196
+ # Serve static
197
+ _STATIC = Path(__file__).parent / "static"
198
+ if _STATIC.exists():
199
+ app.mount("/", StaticFiles(directory=_STATIC, html=True), name="static")
@@ -0,0 +1,230 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>GPhotoTool</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ </head>
9
+ <body class="bg-gray-950 text-gray-100 min-h-screen flex flex-col">
10
+ <header class="border-b border-gray-800 px-4 py-3">
11
+ <div class="max-w-xl mx-auto flex items-center justify-between">
12
+ <h1 class="text-lg font-bold">📸 GPhotoTool</h1>
13
+ <span class="text-xs text-gray-600" id="status-dot">● connected</span>
14
+ </div>
15
+ </header>
16
+
17
+ <main class="flex-1 max-w-xl mx-auto w-full px-4 py-6">
18
+
19
+ <!-- Auth -->
20
+ <div class="mb-5">
21
+ <label class="block text-xs text-gray-500 mb-1">Auth Token</label>
22
+ <input id="auth" type="password" placeholder="androidId=...&app=...&Token=..."
23
+ class="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2.5 text-sm font-mono focus:outline-none focus:border-blue-500 placeholder-gray-600">
24
+ <div class="flex items-center gap-3 mt-1.5">
25
+ <button onclick="saveAuth()" class="text-xs text-blue-500 hover:text-blue-400">Save token</button>
26
+ <span id="auth-status" class="text-xs text-gray-600"></span>
27
+ </div>
28
+ <details class="mt-1.5">
29
+ <summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-400">How to get this token</summary>
30
+ <div class="mt-2 text-xs text-gray-500 space-y-1.5 bg-gray-900/50 rounded-lg p-3">
31
+ <p><strong class="text-gray-400">Prerequisites:</strong> ReVanced or Morphe Google Photos installed on Android + ReVanced GMS (microG)</p>
32
+ <p><strong class="text-gray-400">Steps:</strong></p>
33
+ <ol class="list-decimal list-inside space-y-1">
34
+ <li>Connect phone via USB, enable ADB</li>
35
+ <li>Run: <code class="bg-gray-800 px-1 rounded">adb logcat | grep auth%2Fphotos</code></li>
36
+ <li>Open Morphe/ReVanced Photos → tap profile → sign in to your Google account</li>
37
+ <li>A line starting with <code class="bg-gray-800 px-1 rounded">androidId=...</code> appears — copy the full line</li>
38
+ <li>Paste it above</li>
39
+ </ol>
40
+ <p class="text-yellow-500/80">⚠ Token expires in ~24 hours. Re-capture when upload fails with auth error.</p>
41
+ </div>
42
+ </details>
43
+ </div>
44
+
45
+ <!-- Drop -->
46
+ <div id="drop" class="border-2 border-dashed border-gray-700 rounded-xl p-8 text-center cursor-pointer hover:border-gray-500 transition">
47
+ <div class="text-3xl mb-2">📁</div>
48
+ <p class="font-medium">Drop files here</p>
49
+ <p class="text-sm text-gray-500">or click to browse</p>
50
+ <p class="text-xs text-gray-600 mt-2">ZIP · PDF · APK · ISO · DOC · MP3 · anything</p>
51
+ <input id="fi" type="file" class="hidden">
52
+ </div>
53
+
54
+ <!-- File + Mode -->
55
+ <div id="file" class="hidden mt-3 bg-gray-900 rounded-xl p-4">
56
+ <div class="flex items-center justify-between gap-3 mb-3">
57
+ <div class="min-w-0">
58
+ <p id="fname" class="font-medium truncate"></p>
59
+ <p id="fsize" class="text-sm text-gray-400"></p>
60
+ </div>
61
+ </div>
62
+ <div class="flex gap-2">
63
+ <button onclick="run('both')"
64
+ class="flex-1 bg-blue-600 hover:bg-blue-500 px-3 py-2.5 rounded-lg text-sm font-medium transition disabled:opacity-50">
65
+ Convert + Upload
66
+ </button>
67
+ <button onclick="run('disguise')"
68
+ class="flex-1 bg-gray-700 hover:bg-gray-600 px-3 py-2.5 rounded-lg text-sm font-medium transition disabled:opacity-50">
69
+ Convert Only
70
+ </button>
71
+ <button onclick="run('upload')"
72
+ class="flex-1 bg-gray-700 hover:bg-gray-600 px-3 py-2.5 rounded-lg text-sm font-medium transition disabled:opacity-50">
73
+ Upload Only
74
+ </button>
75
+ </div>
76
+ <p class="text-xs text-gray-600 mt-2">
77
+ <strong>Convert + Upload:</strong> wraps file in video → unlimited GPhotos upload<br>
78
+ <strong>Convert Only:</strong> wraps file in video → download the MP4<br>
79
+ <strong>Upload Only:</strong> skips conversion, uploads file directly (for pre-disguised files)
80
+ </p>
81
+ </div>
82
+
83
+ <!-- Progress -->
84
+ <div id="prog" class="hidden mt-3 bg-gray-900 rounded-xl p-4">
85
+ <div class="flex items-center gap-2 mb-2">
86
+ <div class="w-4 h-4 border-2 border-gray-600 border-t-blue-500 rounded-full animate-spin"></div>
87
+ <span id="pstep" class="text-sm font-medium"></span>
88
+ </div>
89
+ <div class="w-full bg-gray-800 rounded-full h-1.5">
90
+ <div id="pbar" class="bg-blue-500 h-1.5 rounded-full transition-all duration-300" style="width:0%"></div>
91
+ </div>
92
+ <p id="ppct" class="text-xs text-gray-500 mt-1 text-right"></p>
93
+ </div>
94
+
95
+ <!-- Result -->
96
+ <div id="result" class="hidden mt-3 rounded-xl p-4"></div>
97
+
98
+ <!-- History -->
99
+ <div id="hist" class="hidden mt-6">
100
+ <p class="text-xs text-gray-500 mb-2">This session</p>
101
+ <div id="hlist" class="space-y-1.5"></div>
102
+ </div>
103
+
104
+ </main>
105
+
106
+ <footer class="border-t border-gray-800 px-4 py-2 text-center text-xs text-gray-700">
107
+ runs locally · files never leave your machine
108
+ </footer>
109
+
110
+ <script>
111
+ const $=s=>document.getElementById(s);
112
+ let file=null,hist=[];
113
+
114
+ // Drop
115
+ const dz=$('drop');
116
+ dz.onclick=()=>$('fi').click();
117
+ dz.ondragover=e=>{e.preventDefault();dz.classList.add('border-green-500')};
118
+ dz.ondragleave=()=>dz.classList.remove('border-green-500');
119
+ dz.ondrop=e=>{e.preventDefault();dz.classList.remove('border-green-500');if(e.dataTransfer.files[0])pick(e.dataTransfer.files[0])};
120
+ $('fi').onchange=()=>{if($('fi').files[0])pick($('fi').files[0])};
121
+
122
+ function fmt(b){for(const u of['B','KB','MB','GB']){if(b<1024)return(b===Math.floor(b)?b:b.toFixed(1))+' '+u;b/=1024}return b.toFixed(1)+' TB'}
123
+
124
+ function pick(f){
125
+ file=f;$('fname').textContent=f.name;$('fsize').textContent=fmt(f.size);
126
+ $('file').classList.remove('hidden');$('prog').classList.add('hidden');$('result').classList.add('hidden');
127
+ }
128
+
129
+ async function run(mode){
130
+ const auth=$('auth').value.trim();
131
+ if(mode!=='disguise'&&!auth)return alert('Paste auth token');
132
+ if(mode!=='disguise'&&!auth.startsWith('androidId='))return alert('Token must start with androidId=');
133
+ if(!file)return alert('Select a file');
134
+
135
+ $('prog').classList.remove('hidden');$('result').classList.add('hidden');
136
+
137
+ const fd=new FormData();fd.append('file',file);fd.append('mode',mode);
138
+ if(mode!=='disguise')fd.append('auth_data',auth);
139
+
140
+ const labels={disguise:'Converting to video...',upload:'Uploading...',both:'Converting + uploading...'};
141
+
142
+ try{
143
+ setProg(labels[mode]||'Working...',5);
144
+ const r=await fetch('/api/upload',{method:'POST',body:fd});
145
+ if(!r.ok){const e=await r.json();throw new Error(e.detail||'Failed')}
146
+ const data=await r.json();
147
+ setMode(mode);poll(data.task_id);
148
+ }catch(e){setProg('Error: '+e.message,0,true)}
149
+ }
150
+
151
+ let currentMode='both';
152
+ function setMode(m){currentMode=m}
153
+
154
+ async function poll(id){
155
+ try{
156
+ const d=await(await fetch('/api/status/'+id)).json();
157
+ if(d.status==='queued')setProg('Queued...',5);
158
+ else if(d.status==='disguising')setProg(d.step||'Converting...',25);
159
+ else if(d.status==='uploading')setProg('Uploading to Google Photos...',55);
160
+ else if(d.status==='done'){showDone(d);return}
161
+ else if(d.status==='error'){setProg(d.error||'Failed',0,true);return}
162
+ setTimeout(()=>poll(id),2000);
163
+ }catch{setTimeout(()=>poll(id),3000)}
164
+ }
165
+
166
+ function setProg(s,p,err){
167
+ $('pstep').textContent=s;$('pbar').style.width=p+'%';$('ppct').textContent=Math.round(p)+'%';
168
+ if(err)$('pbar').className='bg-red-500 h-1.5 rounded-full transition-all duration-300';
169
+ }
170
+
171
+ function showDone(d){
172
+ $('prog').classList.add('hidden');
173
+ const r=$('result');r.classList.remove('hidden');
174
+ r.className='mt-3 rounded-xl p-4 bg-green-900/20 border border-green-800';
175
+
176
+ let html=`<p class="font-medium text-green-400 mb-1">✅ Done</p>
177
+ <p class="text-sm text-gray-400 mb-3">${d.filename} ${d.elapsed?'('+d.elapsed+'s)':''}</p>`;
178
+
179
+ if(currentMode==='disguise'){
180
+ html+=`<div class="flex flex-wrap gap-2">
181
+ <a href="${d.download_url}" class="bg-green-700 hover:bg-green-600 px-4 py-1.5 rounded-lg text-sm transition">⬇ Download MP4</a>
182
+ <p class="text-xs text-gray-500 w-full mt-1">File wrapped as MP4 — upload with any tool, or paste to "Upload Only"</p>
183
+ </div>`;
184
+ }else{
185
+ html+=`<div class="flex flex-wrap gap-2">
186
+ <a href="${d.photos_url}" target="_blank" class="bg-green-700 hover:bg-green-600 px-4 py-1.5 rounded-lg text-sm transition">Open in Photos →</a>
187
+ <button onclick="navigator.clipboard.writeText('${d.media_key}')" class="bg-gray-700 hover:bg-gray-600 px-4 py-1.5 rounded-lg text-sm transition">Copy key</button>
188
+ </div>`;
189
+ }
190
+
191
+ r.innerHTML=html;
192
+ $('pbar').className='bg-blue-500 h-1.5 rounded-full transition-all duration-300';
193
+ hist.unshift({...d,mode:currentMode});showHist();
194
+ }
195
+
196
+ function showHist(){
197
+ $('hist').classList.remove('hidden');
198
+ $('hlist').innerHTML=hist.map(h=>{
199
+ const badges={both:'⬆',disguise:'🎬',upload:'☁️'};
200
+ const badge=badges[h.mode]||'';
201
+ return `<div class="bg-gray-900 rounded-lg px-3 py-2 flex items-center justify-between text-sm">
202
+ <span class="truncate mr-2">${badge} ${h.filename}</span>
203
+ <div class="shrink-0 flex gap-2">
204
+ ${h.download_url?`<a href="${h.download_url}" class="text-green-400 hover:text-green-300">⬇</a>`:''}
205
+ ${h.photos_url?`<a href="${h.photos_url}" target="_blank" class="text-blue-400 hover:text-blue-300">View →</a>`:''}
206
+ </div>
207
+ </div>`;
208
+ }).join('');
209
+ }
210
+
211
+ // Health + saved auth
212
+ Promise.all([fetch('/api/health').then(r=>r.json()), fetch('/api/auth').then(r=>r.json())])
213
+ .then(([h,a])=>{
214
+ if(h.gpmc&&h.gpd)$('status-dot').textContent='● ready';
215
+ else{$('status-dot').textContent='● missing tools';$('status-dot').className='text-red-500'}
216
+ if(a.saved){$('auth').value=a.preview;$('auth').title='Saved token loaded — paste fresh if expired'}
217
+ });
218
+
219
+ async function saveAuth(){
220
+ const v=$('auth').value.trim();
221
+ if(!v)return alert('Paste a token first');
222
+ if(!v.startsWith('androidId=')||!v.includes('&Token='))return alert('Invalid token format');
223
+ const fd=new FormData();fd.append('auth_data',v);
224
+ const r=await fetch('/api/auth',{method:'POST',body:fd});
225
+ if(r.ok)$('auth-status').textContent='✓ Saved';
226
+ else $('auth-status').textContent='✗ Failed';
227
+ }
228
+ </script>
229
+ </body>
230
+ </html>
@@ -0,0 +1,149 @@
1
+ """Background worker — disguise + upload."""
2
+ import subprocess, os, time, shutil, logging, ast
3
+ from pathlib import Path
4
+
5
+ log = logging.getLogger("gphototool")
6
+
7
+ MAX_CONCURRENT = 3
8
+ import threading
9
+ sem = threading.Semaphore(MAX_CONCURRENT)
10
+
11
+
12
+ def _run(cmd, env=None, timeout=3600):
13
+ try:
14
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env or os.environ)
15
+ return r.returncode, r.stdout + r.stderr
16
+ except subprocess.TimeoutExpired:
17
+ return -1, "timeout"
18
+ except Exception as e:
19
+ return -2, str(e)
20
+
21
+
22
+ def is_already_disguised(path: Path) -> bool:
23
+ """Check if file is already a gpd-disguised MP4 by trying extract."""
24
+ code, _ = _run(["gpd", "extract", str(path), "-o", "/dev/null"], timeout=30)
25
+ return code == 0
26
+
27
+
28
+ def disguise_file(src: Path, out_dir: Path, size: int) -> tuple[Path | None, str]:
29
+ """Disguise src as MP4. Returns (output_path, error_msg)."""
30
+ out_mp4 = out_dir / f"{src.stem}.mp4"
31
+ timeout = max(300, size // (1024 * 1024))
32
+ code, out = _run(["gpd", "hide", "-t", "video", "-o", str(out_mp4), str(src)], timeout=timeout)
33
+
34
+ if code != 0 or not out_mp4.exists():
35
+ if "ffmpeg" in out.lower() or "not found" in out.lower():
36
+ return None, "ffmpeg not found. Install it: apt install ffmpeg / brew install ffmpeg"
37
+ return None, f"Conversion failed: {out[-200:]}"
38
+ return out_mp4, ""
39
+
40
+
41
+ def upload_file(mp4: Path, auth: str) -> tuple[str | None, str]:
42
+ """Upload MP4 to GPhotos. Returns (media_key, error_msg)."""
43
+ env = {**os.environ, "GP_AUTH_DATA": auth}
44
+ code, out = _run(
45
+ ["gpmc", str(mp4), "--progress", "--timeout", "7200", "--log-level", "WARNING"],
46
+ env=env, timeout=7200,
47
+ )
48
+
49
+ if code != 0:
50
+ err = out.strip().split("\n")[-1][:300] if out.strip() else "unknown error"
51
+ if "401" in err or "Unauthorized" in err or "auth" in err.lower():
52
+ err = "Auth token expired. Capture a fresh token from adb logcat."
53
+ elif "403" in err or "Forbidden" in err:
54
+ err = "Account may be banned or rate-limited. Wait 24h and try fresh token."
55
+ elif "timeout" in err.lower():
56
+ err = "Upload timed out. File may be too large or connection slow."
57
+ return None, f"Upload failed: {err}"
58
+
59
+ # Parse media key — gpmc outputs Python dict with single quotes, not JSON
60
+ media_key = None
61
+ for line in out.strip().split("\n"):
62
+ if "AF1Qip" in line:
63
+ brace = line.rfind("{")
64
+ if brace >= 0:
65
+ try:
66
+ d = ast.literal_eval(line[brace:].rstrip())
67
+ media_key = list(d.values())[0]
68
+ except Exception:
69
+ pass
70
+
71
+ if not media_key:
72
+ return None, "No media key in gpmc output"
73
+ return media_key, ""
74
+
75
+
76
+ def process_file(t: dict, src: Path, auth: str, upload_dir: Path, mode: str):
77
+ """Run disguise/upload in background thread. mode: disguise|upload|both"""
78
+ sem.acquire()
79
+ try:
80
+ _process_inner(t, src, auth, upload_dir, mode)
81
+ finally:
82
+ sem.release()
83
+ if mode != "disguise":
84
+ d = upload_dir / t["id"]
85
+ if d.exists():
86
+ shutil.rmtree(d, ignore_errors=True)
87
+
88
+
89
+ def _process_inner(t: dict, src: Path, auth: str, upload_dir: Path, mode: str):
90
+ task_id = t["id"]
91
+ out_dir = upload_dir / task_id
92
+ out_dir.mkdir(exist_ok=True)
93
+
94
+ mp4_to_upload = src # may be src itself if already disguised
95
+
96
+ # ── Disguise step (skip if already disguised or mode=upload) ──
97
+ if mode != "upload":
98
+ # Check if already disguised
99
+ if is_already_disguised(src):
100
+ t["status"] = "disguising"
101
+ t["step"] = "Already disguised — skipping conversion"
102
+ _save(t)
103
+ log.info("[%s] already disguised, skipping", task_id)
104
+ mp4_to_upload = src
105
+ else:
106
+ t["status"] = "disguising"
107
+ t["step"] = "Converting to video..."
108
+ _save(t)
109
+ log.info("[%s] disguising %s", task_id, t["filename"])
110
+
111
+ mp4_path, err = disguise_file(src, out_dir, t["size"])
112
+ if err:
113
+ t["status"] = "error"
114
+ t["error"] = err
115
+ _save(t)
116
+ return
117
+ mp4_to_upload = mp4_path
118
+
119
+ t["disguised_size"] = mp4_to_upload.stat().st_size
120
+
121
+ # ── Upload step (skip if mode=disguise) ──
122
+ if mode != "disguise":
123
+ t["status"] = "uploading"
124
+ t["step"] = "Uploading to Google Photos..."
125
+ _save(t)
126
+ log.info("[%s] uploading %s", task_id, t["filename"])
127
+
128
+ media_key, err = upload_file(mp4_to_upload, auth)
129
+ if err:
130
+ t["status"] = "error"
131
+ t["error"] = err
132
+ _save(t)
133
+ return
134
+
135
+ t["media_key"] = media_key
136
+ t["photos_url"] = f"https://photos.google.com/share/{media_key}"
137
+
138
+ # ── Done ──
139
+ t["status"] = "done"
140
+ t["step"] = "Complete"
141
+ t["elapsed"] = round(time.time() - t["created"], 1)
142
+ _save(t)
143
+ log.info("[%s] done (mode=%s)", task_id, mode)
144
+
145
+
146
+ def _save(t: dict):
147
+ from gphototool.server import tasks, lock
148
+ with lock:
149
+ tasks[t["id"]] = t