Compare commits

...

9 Commits

Author SHA1 Message Date
7e11b2d531 Prepare for Docker deployment 2025-11-23 11:32:48 -07:00
7b34cb0340 Remove another Immich reference 2025-11-23 11:32:30 -07:00
17cd5b32da refactor: Load environment variables from .env file
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-23 11:21:51 -07:00
c4e677aa5c Freeze requirements 2025-11-23 11:18:46 -07:00
c89f66f550 Update example .env 2025-11-23 11:18:34 -07:00
7e83d0806c refactor: Move album storage into nested 'albums' directory
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-23 10:47:59 -07:00
53dcbf2f95 refactor: Simplify pytz import and remove conditional checks 2025-11-23 10:47:56 -07:00
be5c8dec26 feat: Organize public uploads into daily directories based on timezone
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-23 10:43:16 -07:00
979354f87b gitkeep data/ 2025-11-23 10:38:08 -07:00
8 changed files with 47 additions and 50 deletions

View File

@@ -1,16 +1,12 @@
# Server (dev only)
HOST=0.0.0.0 HOST=0.0.0.0
PORT=8080 PORT=8080
# Immich connection (include /api)
IMMICH_BASE_URL=http://REPLACE_ME:2283/api
IMMICH_API_KEY=ADD-YOUR-API-KEY # needs: asset.upload; for albums also: album.create, album.read, albumAsset.create
MAX_CONCURRENT=3 MAX_CONCURRENT=3
# Public uploader page (optional) — disabled by default ADMIN_PASSWORD=test123
PUBLIC_UPLOAD_PAGE_ENABLED=TRUE TIMEZONE=America/Edmonton
# Album (optional): auto-add uploads from public uploader to this album (creates if needed) # Public uploader page (optional) — disabled by default
IMMICH_ALBUM_NAME=dead-drop PUBLIC_UPLOAD_PAGE_ENABLED=false
# Local dedupe cache (SQLite) # Local dedupe cache (SQLite)
STATE_DB=./data/state.db STATE_DB=./data/state.db
@@ -19,13 +15,14 @@ STATE_DB=./data/state.db
# e.g., PUBLIC_BASE_URL=https://photos.example.com # e.g., PUBLIC_BASE_URL=https://photos.example.com
#PUBLIC_BASE_URL= #PUBLIC_BASE_URL=
# Session and security LOG_LEVEL=INFO
SESSION_SECRET=SET-A-STRONG-RANDOM-VALUE
LOG_LEVEL=DEBUG
# Chunked uploads (to work around 100MB proxy limits) # Chunked uploads (to work around 100MB proxy limits)
# Enable to send files in chunks from browser to this service; the service reassembles and forwards to Immich. # Enable to send files in chunks from browser to this service
# Recommended chunk size for Cloudflare Tunnel is <= 95MB. # Recommended chunk size for Cloudflare Tunnel is <= 95MB.
CHUNKED_UPLOADS_ENABLED=false CHUNKED_UPLOADS_ENABLED=false
CHUNK_SIZE_MB=95 CHUNK_SIZE_MB=95
# Custom session secrets
# By default, a random one is generated
#SESSION_SECRET=SET-A-STRONG-RANDOM-VALUE

View File

@@ -1,28 +1,27 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
FROM python:3.11-slim FROM python:3.11-slim
WORKDIR /immich_drop WORKDIR /image_drop
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
# Install Python deps
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt \
&& pip install --no-cache-dir python-multipart
# Copy app code # Copy app code
COPY . /immich_drop COPY . /image_drop
# Install Python deps
RUN pip install --no-cache-dir -r requirements.txt \
&& pip install --no-cache-dir python-multipart
# Data dir for SQLite (state.db) # Data dir for SQLite (state.db)
RUN mkdir -p /data #RUN mkdir -p /data
VOLUME ["/data"] #VOLUME ["/data"]
# Defaults (can be overridden via compose env) # Defaults (can be overridden via compose env)
ENV HOST=0.0.0.0 \ ENV HOST=0.0.0.0 \
PORT=8080 \ PORT=8080 \
STATE_DB=/data/state.db STATE_DB=/image_drop/data/state.db
EXPOSE 8080 EXPOSE 8080

View File

@@ -17,6 +17,7 @@ import hashlib
import os import os
import sqlite3 import sqlite3
import binascii import binascii
import pytz
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional from typing import Dict, List, Optional
@@ -256,8 +257,19 @@ def get_or_create_album_dir(album_name: str) -> str:
"""Get or create a directory for an album. Returns the path.""" """Get or create a directory for an album. Returns the path."""
if not album_name or not isinstance(album_name, str): if not album_name or not isinstance(album_name, str):
album_name = "public" album_name = "public"
safe_album_name = sanitize_filename(album_name)
save_dir = os.path.join("./data/uploads", safe_album_name) if album_name == "public":
try:
tz = pytz.timezone(SETTINGS.timezone)
today = datetime.now(tz).strftime('%Y-%m-%d')
save_dir = os.path.join("./data/uploads", "public", today)
except Exception as e:
logger.warning("Timezone logic failed, falling back to 'public' album. Timezone: %s. Error: %s", SETTINGS.timezone, e)
save_dir = os.path.join("./data/uploads", "public")
else:
safe_album_name = sanitize_filename(album_name)
save_dir = os.path.join("./data/uploads", "albums", safe_album_name)
os.makedirs(save_dir, exist_ok=True) os.makedirs(save_dir, exist_ok=True)
return save_dir return save_dir
@@ -926,13 +938,14 @@ async def api_albums(request: Request) -> JSONResponse:
return JSONResponse({"error": "unauthorized"}, status_code=401) return JSONResponse({"error": "unauthorized"}, status_code=401)
upload_root = "./data/uploads" upload_root = "./data/uploads"
albums_root = os.path.join(upload_root, "albums")
try: try:
os.makedirs(upload_root, exist_ok=True) os.makedirs(albums_root, exist_ok=True)
# also make public dir # also make public dir
os.makedirs(os.path.join(upload_root, "public"), exist_ok=True) os.makedirs(os.path.join(upload_root, "public"), exist_ok=True)
albums = [] albums = [{"id": "public", "albumName": "public"}]
for name in os.listdir(upload_root): for name in os.listdir(albums_root):
if os.path.isdir(os.path.join(upload_root, name)): if os.path.isdir(os.path.join(albums_root, name)):
albums.append({"id": name, "albumName": name}) albums.append({"id": name, "albumName": name})
return JSONResponse(albums) return JSONResponse(albums)
except Exception as e: except Exception as e:

View File

@@ -24,6 +24,7 @@ class Settings:
log_level: str = "INFO" log_level: str = "INFO"
chunked_uploads_enabled: bool = False chunked_uploads_enabled: bool = False
chunk_size_mb: int = 95 chunk_size_mb: int = 95
timezone: str = "UTC"
def _hash_password(pw: str) -> str: def _hash_password(pw: str) -> str:
"""Return PBKDF2-SHA256 hash of a password.""" """Return PBKDF2-SHA256 hash of a password."""
@@ -71,6 +72,7 @@ def load_settings() -> Settings:
chunk_size_mb = int(os.getenv("CHUNK_SIZE_MB", "95")) chunk_size_mb = int(os.getenv("CHUNK_SIZE_MB", "95"))
except ValueError: except ValueError:
chunk_size_mb = 95 chunk_size_mb = 95
timezone = os.getenv("TIMEZONE", "UTC")
return Settings( return Settings(
admin_password=admin_password, admin_password=admin_password,
max_concurrent=maxc, max_concurrent=maxc,
@@ -81,4 +83,5 @@ def load_settings() -> Settings:
log_level=log_level, log_level=log_level,
chunked_uploads_enabled=chunked_uploads_enabled, chunked_uploads_enabled=chunked_uploads_enabled,
chunk_size_mb=chunk_size_mb, chunk_size_mb=chunk_size_mb,
timezone=timezone,
) )

0
data/.gitkeep Normal file
View File

View File

@@ -1,33 +1,17 @@
services: services:
immich-drop: image-drop:
build: . build: .
container_name: immich-drop container_name: image-drop
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8080:8080" - "8080:8080"
env_file:
environment: - .env
#immich drop server ip
PUBLIC_BASE_URL: http://192.168.8.60:8080
# Immich connection
IMMICH_BASE_URL: http://192.168.8.60:2283/api
IMMICH_API_KEY: n7lO2oRFVhMXqI10YL8nfelIC9lZ8ND8AxZqx1XHiA
#Enable/Disable Public upload page to folder
PUBLIC_UPLOAD_PAGE_ENABLED: false
IMMICH_ALBUM_NAME: dead-drop
# Chunked uploads to bypass 100MB limits (e.g., Cloudflare Tunnel)
CHUNKED_UPLOADS_ENABLED: true
CHUNK_SIZE_MB: 95
volumes: volumes:
- immich_drop_data:/data - ./data:/image_drop/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "python - <<'PY'\nimport urllib.request,sys\ntry:\n urllib.request.urlopen('http://localhost:8080/').read(); sys.exit(0)\nexcept Exception:\n sys.exit(1)\nPY"] test: ["CMD-SHELL", "python - <<'PY'\nimport urllib.request,sys\ntry:\n urllib.request.urlopen('http://localhost:8080/').read(); sys.exit(0)\nexcept Exception:\n sys.exit(1)\nPY"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 10s start_period: 10s
volumes:
immich_drop_data:

View File

@@ -14,7 +14,7 @@
<div class="mx-auto max-w-2xl p-6 space-y-6"> <div class="mx-auto max-w-2xl p-6 space-y-6">
<div id="topBanner" class="hidden rounded-2xl border border-green-200 bg-green-50 p-3 text-green-700 text-center dark:bg-green-900 dark:border-green-700 dark:text-green-300"></div> <div id="topBanner" class="hidden rounded-2xl border border-green-200 bg-green-50 p-3 text-green-700 text-center dark:bg-green-900 dark:border-green-700 dark:text-green-300"></div>
<header class="flex items-center justify-between"> <header class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Login to Immich</h1> <h1 class="text-2xl font-semibold">Login to Image Drop</h1>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a id="linkPublicUploader" href="/" class="hidden rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Public uploader</a> <a id="linkPublicUploader" href="/" class="hidden rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Public uploader</a>
<button id="btnTheme" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600" title="Toggle dark mode"> <button id="btnTheme" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600" title="Toggle dark mode">

View File

@@ -16,6 +16,7 @@ pydantic==2.11.7
pydantic_core==2.33.2 pydantic_core==2.33.2
python-dotenv==1.1.1 python-dotenv==1.1.1
python-multipart==0.0.20 python-multipart==0.0.20
pytz==2025.2
PyYAML==6.0.2 PyYAML==6.0.2
qrcode==8.2 qrcode==8.2
requests==2.32.5 requests==2.32.5