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.
- gphototool-0.1.0/.gitignore +9 -0
- gphototool-0.1.0/ARCHITECTURE.md +47 -0
- gphototool-0.1.0/PKG-INFO +84 -0
- gphototool-0.1.0/README.md +70 -0
- gphototool-0.1.0/docs/01-specification.md +31 -0
- gphototool-0.1.0/docs/02-architecture.md +104 -0
- gphototool-0.1.0/docs/03-pseudocode.md +80 -0
- gphototool-0.1.0/docs/AUTH_TOKEN.md +80 -0
- gphototool-0.1.0/pyproject.toml +29 -0
- gphototool-0.1.0/src/gphototool/__init__.py +2 -0
- gphototool-0.1.0/src/gphototool/__main__.py +3 -0
- gphototool-0.1.0/src/gphototool/cli.py +48 -0
- gphototool-0.1.0/src/gphototool/server.py +199 -0
- gphototool-0.1.0/src/gphototool/static/index.html +230 -0
- gphototool-0.1.0/src/gphototool/worker.py +149 -0
|
@@ -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,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
|