diff --git a/README.md b/README.md index f6c6960..5a721f7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ A tiny, zero-login web app for collecting photos/videos into your **Immich** server. +![Immich Drop Uploader Dark Mode UI](./screenshot.png) + +## Features + - **No accounts** — open the page, drop files, done - **Queue with progress** via WebSocket (success / duplicate / error) - **Duplicate prevention** (local SHA‑1 cache + optional Immich bulk‑check) @@ -9,16 +13,18 @@ A tiny, zero-login web app for collecting photos/videos into your **Immich** ser - **Mobile‑friendly** - **.env‑only config** (clean deploys) + Docker/Compose - **Privacy‑first**: never lists server media; UI only shows the current session +- **Dark mode support** — automatically detects system preference, with manual toggle +- **Album integration** — auto-adds uploads to a configured album (creates if needed) --- ## Table of contents -- [Quick start](#Quick_start) +- [Quick start](#quick-start) +- [New Features](#new-features) - [Architecture](#architecture) - [Folder structure](#folder-structure) - [Requirements](#requirements) - [Configuration (.env)](#configuration-env) -- [Quick start (Docker/Compose)](#quick-start-dockercompose) - [How it works](#how-it-works) - [Mobile notes](#mobile-notes) - [Troubleshooting](#troubleshooting) @@ -30,8 +36,9 @@ A tiny, zero-login web app for collecting photos/videos into your **Immich** ser ## Quick start Copy the docker-compose.yml and the .env file to a common folder, update the .env file before executing the CLI commands to quick start the container. + ### docker-compose.yml -``` +```yaml version: "3.9" services: @@ -41,6 +48,10 @@ services: container_name: immich-drop restart: unless-stopped + # Optional: Set album name for auto-adding uploads + environment: + IMMICH_ALBUM_NAME: dead-drop # Optional: uploads will be added to this album + # Load all variables from your repo's .env (PORT, IMMICH_BASE_URL, IMMICH_API_KEY, etc.) env_file: - ./.env @@ -64,6 +75,7 @@ services: volumes: immich_drop_data: ``` + ### .env ``` @@ -72,18 +84,42 @@ PORT=8080 IMMICH_BASE_URL=http://REPLACE_ME:2283/api IMMICH_API_KEY=REPLACE_ME MAX_CONCURRENT=3 +IMMICH_ALBUM_NAME=dead-drop # Optional: auto-add uploads to this album STATE_DB=/data/state.db ``` + ### CLI -``` +```bash docker compose pull docker compose up -d ``` --- +## New Features + +### 🌙 Dark Mode +- Automatically detects system dark/light preference on first visit +- Manual toggle button in the header (sun/moon icon) +- Preference saved in browser localStorage +- Smooth color transitions for better UX +- All UI elements properly themed for both modes + +### 📁 Album Integration +- Configure `IMMICH_ALBUM_NAME` environment variable to auto-add uploads to a specific album +- Album is automatically created if it doesn't exist +- Efficient caching of album ID to minimize API calls +- Visual feedback showing which album uploads are being added to +- Works seamlessly with existing duplicate detection + +### 🐛 Bug Fixes +- Fixed WebSocket disconnection error that occurred when clients closed connections +- Improved error handling for edge cases + +--- + ## Architecture -- **Frontend:** static HTML/JS (Tailwind). Drag & drop or “Choose files”, queue UI with progress and status chips. +- **Frontend:** static HTML/JS (Tailwind). Drag & drop or "Choose files", queue UI with progress and status chips. - **Backend:** FastAPI + Uvicorn. - Proxies uploads to Immich `/assets` - Computes SHA‑1 and checks a local SQLite cache (`state.db`) @@ -132,6 +168,9 @@ IMMICH_BASE_URL=http://REPLACE_ME:2283/api IMMICH_API_KEY=REPLACE_ME MAX_CONCURRENT=3 +# Optional: Album name for auto-adding uploads (creates if doesn't exist) +IMMICH_ALBUM_NAME=dead-drop + # Local dedupe cache STATE_DB=./data/state.db # local dev -> ./state.db (data folder is created in docker image) # In Docker this is overridden to /data/state.db by docker-compose.yml @@ -142,23 +181,6 @@ You can keep a checked‑in `/.env.example` with the keys above for onboarding. --- -## Quick start (Docker/Compose) - -1) Put your settings in **.env** at the repo root (see below). -2) Build & run: - -```bash -docker compose build -docker compose up -d -# open http://localhost:8080 -``` - -A named volume stores `/data/state.db` so duplicates are remembered across container restarts. - ---- - - - ## How it works 1. **Queue** – Files selected in the browser are queued; each gets a client‑side ID. @@ -168,8 +190,9 @@ A named volume stores `/data/state.db` so duplicates are remembered across conta - `assetData`, `deviceAssetId`, `deviceId`, - `fileCreatedAt`, `fileModifiedAt` (from EXIF when available; else `lastModified`), - `isFavorite=false`, `filename`, and header `x-immich-checksum`. -5. **Progress** – Backend streams progress via WebSocket to the same session. -6. **Privacy** – UI shows only the current session’s items. It never lists server media. +5. **Album** – If `IMMICH_ALBUM_NAME` is configured, adds the uploaded asset to the album (creates album if it doesn't exist). +6. **Progress** – Backend streams progress via WebSocket to the same session. +7. **Privacy** – UI shows only the current session's items. It never lists server media. --- @@ -183,7 +206,7 @@ A named volume stores `/data/state.db` so duplicates are remembered across conta ## Troubleshooting -**Uploads don’t start on phones / picker re‑opens** +**Uploads don't start on phones / picker re‑opens** – Hard‑refresh; current UI suppresses ghost clicks and resets the input. – If using a PWA/WebView, test in Safari/Chrome directly to rule out container quirks. @@ -224,4 +247,4 @@ The backend contains docstrings so you can generate docs later if desired. ## License -MIT. +MIT. \ No newline at end of file diff --git a/app/app.py b/app/app.py index b1afed0..222e8bd 100644 --- a/app/app.py +++ b/app/app.py @@ -53,6 +53,9 @@ app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static") # Global settings (read-only at runtime) SETTINGS: Settings = load_settings() +# Album cache +ALBUM_ID: Optional[str] = None + # ---------- DB (local dedupe cache) ---------- def db_init() -> None: @@ -143,12 +146,15 @@ class SessionHub: async def disconnect(self, session_id: str, ws: WebSocket) -> None: """Remove a socket from the hub and close it (best-effort).""" - try: - await ws.close() - finally: - if session_id in self.sessions and ws in self.sessions[session_id]: - self.sessions[session_id].remove(ws) - self._cleanup_closed(session_id) + if session_id in self.sessions and ws in self.sessions[session_id]: + self.sessions[session_id].remove(ws) + self._cleanup_closed(session_id) + # Only try to close if the connection is still open + if ws.client_state == WebSocketState.CONNECTED: + try: + await ws.close() + except Exception: + pass hub = SessionHub() @@ -190,6 +196,78 @@ def immich_headers() -> dict: """Headers for Immich API calls (keeps key server-side).""" return {"Accept": "application/json", "x-api-key": SETTINGS.immich_api_key} +def get_or_create_album() -> Optional[str]: + """Get existing album by name or create a new one. Returns album ID or None.""" + global ALBUM_ID + + # Skip if no album name configured + if not SETTINGS.album_name: + return None + + # Return cached album ID if already fetched + if ALBUM_ID: + return ALBUM_ID + + try: + # First, try to find existing album + url = f"{SETTINGS.normalized_base_url}/albums" + r = requests.get(url, headers=immich_headers(), timeout=10) + + if r.status_code == 200: + albums = r.json() + for album in albums: + if album.get("albumName") == SETTINGS.album_name: + ALBUM_ID = album.get("id") + print(f"Found existing album '{SETTINGS.album_name}' with ID: {ALBUM_ID}") + return ALBUM_ID + + # Album doesn't exist, create it + create_url = f"{SETTINGS.normalized_base_url}/albums" + payload = { + "albumName": SETTINGS.album_name, + "description": "Auto-created album for Immich Drop uploads" + } + r = requests.post(create_url, headers={**immich_headers(), "Content-Type": "application/json"}, + json=payload, timeout=10) + + if r.status_code in (200, 201): + data = r.json() + ALBUM_ID = data.get("id") + print(f"Created new album '{SETTINGS.album_name}' with ID: {ALBUM_ID}") + return ALBUM_ID + else: + print(f"Failed to create album: {r.status_code} - {r.text}") + except Exception as e: + print(f"Error managing album: {e}") + + return None + +def add_asset_to_album(asset_id: str) -> bool: + """Add an asset to the configured album. Returns True on success.""" + album_id = get_or_create_album() + if not album_id or not asset_id: + return False + + try: + url = f"{SETTINGS.normalized_base_url}/albums/{album_id}/assets" + payload = {"ids": [asset_id]} + r = requests.put(url, headers={**immich_headers(), "Content-Type": "application/json"}, + json=payload, timeout=10) + + if r.status_code == 200: + results = r.json() + # Check if any result indicates success + for result in results: + if result.get("success"): + return True + elif result.get("error") == "duplicate": + # Asset already in album, consider it success + return True + return False + except Exception as e: + print(f"Error adding asset to album: {e}") + return False + def immich_ping() -> bool: """Best-effort reachability check against a few Immich endpoints.""" if not SETTINGS.immich_api_key: @@ -236,7 +314,11 @@ async def index(_: Request) -> HTMLResponse: @app.post("/api/ping") async def api_ping() -> dict: """Connectivity test endpoint used by the UI to display a temporary banner.""" - return {"ok": immich_ping(), "base_url": SETTINGS.normalized_base_url} + return { + "ok": immich_ping(), + "base_url": SETTINGS.normalized_base_url, + "album_name": SETTINGS.album_name if SETTINGS.album_name else None + } @app.websocket("/ws") async def ws_endpoint(ws: WebSocket) -> None: @@ -332,6 +414,12 @@ async def api_upload( asset_id = data.get("id") db_insert_upload(checksum, file.filename, size, device_asset_id, asset_id, created_iso) status = data.get("status", "created") + + # Add to album if configured + if SETTINGS.album_name and asset_id: + if add_asset_to_album(asset_id): + status += f" (added to album '{SETTINGS.album_name}')" + await send_progress(session_id, item_id, "duplicate" if status == "duplicate" else "done", 100, status, asset_id) return JSONResponse({"id": asset_id, "status": status}, status_code=200) else: diff --git a/app/config.py b/app/config.py index 16e6f43..c964147 100644 --- a/app/config.py +++ b/app/config.py @@ -14,6 +14,7 @@ class Settings: immich_base_url: str immich_api_key: str max_concurrent: int = 3 + album_name: str = "" @property def normalized_base_url(self) -> str: @@ -24,8 +25,9 @@ def load_settings() -> Settings: """Load settings from .env, applying defaults when absent.""" base = os.getenv("IMMICH_BASE_URL", "http://127.0.0.1:2283/api") api_key = os.getenv("IMMICH_API_KEY", "") + album_name = os.getenv("IMMICH_ALBUM_NAME", "") try: maxc = int(os.getenv("MAX_CONCURRENT", "3")) except ValueError: maxc = 3 - return Settings(immich_base_url=base, immich_api_key=api_key, max_concurrent=maxc) + return Settings(immich_base_url=base, immich_api_key=api_key, max_concurrent=maxc, album_name=album_name) diff --git a/docker-compose.yml b/docker-compose.yml index 9fe3f40..a915c8e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,9 @@ services: ports: - "8080:8080" + environment: + IMMICH_ALBUM_NAME: dead-drop + env_file: - ./.env diff --git a/frontend/app.js b/frontend/app.js index ba3c018..14f5579 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -3,6 +3,29 @@ const sessionId = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.ra let items = []; let socket; +// --- Dark mode --- +function initDarkMode() { + const stored = localStorage.getItem('theme'); + if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + document.documentElement.classList.add('dark'); + } + updateThemeIcon(); +} + +function toggleDarkMode() { + const isDark = document.documentElement.classList.toggle('dark'); + localStorage.setItem('theme', isDark ? 'dark' : 'light'); + updateThemeIcon(); +} + +function updateThemeIcon() { + const isDark = document.documentElement.classList.contains('dark'); + document.getElementById('iconLight').classList.toggle('hidden', !isDark); + document.getElementById('iconDark').classList.toggle('hidden', isDark); +} + +initDarkMode(); + // --- helpers --- function human(bytes){ if (!bytes) return '0 B'; @@ -21,20 +44,20 @@ function addItem(file){ function render(){ const itemsEl = document.getElementById('items'); itemsEl.innerHTML = items.map(it => ` -
+
-
${it.name} (${human(it.size)})
-
+
${it.name} (${human(it.size)})
+
${it.message ? `${it.message}` : ''}
${it.status}
-
+
-
+
${it.status==='uploading' ? `Uploading… ${it.progress}%` : it.status.charAt(0).toUpperCase()+it.status.slice(1)}
@@ -116,6 +139,7 @@ const btnClearAll = document.getElementById('btnClearAll'); const btnPing = document.getElementById('btnPing'); const pingStatus = document.getElementById('pingStatus'); const banner = document.getElementById('topBanner'); +const btnTheme = document.getElementById('btnTheme'); // --- Connection test with ephemeral banner --- btnPing.onclick = async () => { @@ -126,9 +150,13 @@ btnPing.onclick = async () => { pingStatus.textContent = j.ok ? 'Connected' : 'No connection'; pingStatus.className = 'ml-2 text-sm ' + (j.ok ? 'text-green-600' : 'text-red-600'); if(j.ok){ - banner.textContent = `Connected to Immich at ${j.base_url}`; + let bannerText = `Connected to Immich at ${j.base_url}`; + if(j.album_name) { + bannerText += ` | Uploading to album: "${j.album_name}"`; + } + banner.textContent = bannerText; banner.classList.remove('hidden'); - setTimeout(() => banner.classList.add('hidden'), 3000); + setTimeout(() => banner.classList.add('hidden'), 4000); } }catch{ pingStatus.textContent = 'No connection'; @@ -137,8 +165,8 @@ btnPing.onclick = async () => { }; // --- Drag & drop (no click-to-open on touch) --- -['dragenter','dragover'].forEach(ev => dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.add('border-blue-500','bg-blue-50'); })); -['dragleave','drop'].forEach(ev => dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.remove('border-blue-500','bg-blue-50'); })); +['dragenter','dragover'].forEach(ev => dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.add('border-blue-500','bg-blue-50','dark:bg-blue-900','dark:bg-opacity-20'); })); +['dragleave','drop'].forEach(ev => dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.remove('border-blue-500','bg-blue-50','dark:bg-blue-900','dark:bg-opacity-20'); })); dz.addEventListener('drop', (e)=>{ e.preventDefault(); const files = Array.from(e.dataTransfer.files || []); @@ -187,3 +215,6 @@ if (!isTouch) { // --- Clear buttons --- btnClearFinished.onclick = ()=>{ items = items.filter(i => !['done','duplicate'].includes(i.status)); render(); }; btnClearAll.onclick = ()=>{ items = []; render(); }; + +// --- Dark mode toggle --- +btnTheme.onclick = toggleDarkMode; diff --git a/frontend/index.html b/frontend/index.html index a295a40..c7b227c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,32 +5,45 @@ Immich Drop Uploader + - +
- +

Immich Drop Uploader

- - + + +
-
+

Drop images or videos here

-

...or

+

...or

-
-
+
We never show uploaded media and keep everything session-local. No account required.
-
+
Queued/Processing: 0 @@ -56,8 +69,8 @@ Errors: 0
- - + +
@@ -65,7 +78,7 @@
-
diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..35a04f3 Binary files /dev/null and b/screenshot.png differ