7630 lines
276 KiB
Python
7630 lines
276 KiB
Python
"""
|
|
Private Gallery Router
|
|
|
|
API endpoints for the Private Gallery feature:
|
|
- Authentication (setup, unlock, lock, change-password)
|
|
- Configuration management
|
|
- Relationship types CRUD
|
|
- Persons CRUD
|
|
- Media CRUD with encryption
|
|
- File upload/copy with encryption
|
|
- File serving with on-the-fly decryption
|
|
- Batch operations
|
|
- Export (decrypted downloads)
|
|
- Albums (auto-generated from persons)
|
|
- Statistics
|
|
"""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import re
|
|
import shutil
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, date, timedelta
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
import threading
|
|
from threading import Lock
|
|
from typing import Dict, List, Optional, Any
|
|
from zipfile import ZipFile
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response, UploadFile, File, Form, Header
|
|
from fastapi.responses import StreamingResponse, FileResponse
|
|
from pydantic import BaseModel, Field
|
|
from slowapi import Limiter
|
|
from slowapi.util import get_remote_address
|
|
|
|
from ..core.dependencies import get_current_user, get_app_state
|
|
from ..core.exceptions import handle_exceptions, NotFoundError, ValidationError, AuthError
|
|
from ..core.responses import message_response, now_iso8601
|
|
from modules.universal_logger import get_logger
|
|
from modules.private_gallery_crypto import get_private_gallery_crypto
|
|
|
|
logger = get_logger('API')
|
|
|
|
router = APIRouter(prefix="/api/private-gallery", tags=["Private Gallery"])
|
|
limiter = Limiter(key_func=get_remote_address)
|
|
|
|
# Custom header for private gallery auth
|
|
PRIVATE_GALLERY_TOKEN_HEADER = "X-Private-Gallery-Token"
|
|
|
|
# In-memory cache for decrypted post lists (avoids re-decrypting all posts on every page)
|
|
_posts_cache: Dict[str, Any] = {}
|
|
_posts_cache_time: Dict[str, float] = {} # cache_key -> timestamp
|
|
_posts_cache_version: int = 0
|
|
_posts_cache_lock = Lock()
|
|
_POSTS_CACHE_TTL = 30 # seconds — ensures external DB changes (e.g. reddit monitor) are picked up
|
|
|
|
def _invalidate_posts_cache():
|
|
"""Bump version to invalidate cached decrypted post lists."""
|
|
global _posts_cache_version
|
|
with _posts_cache_lock:
|
|
_posts_cache_version += 1
|
|
_posts_cache.clear()
|
|
_posts_cache_time.clear()
|
|
|
|
|
|
# In-memory config cache (avoids DB query on every thumbnail/file request)
|
|
_config_cache: Dict[str, Any] = {}
|
|
_config_cache_time: float = 0
|
|
_config_cache_lock = Lock()
|
|
_CONFIG_CACHE_TTL = 30 # seconds
|
|
|
|
|
|
def _invalidate_config_cache():
|
|
"""Invalidate the config cache (call after config changes)."""
|
|
global _config_cache_time
|
|
with _config_cache_lock:
|
|
_config_cache_time = 0
|
|
_config_cache.clear()
|
|
|
|
|
|
# In-memory LRU cache for decrypted thumbnails
|
|
# Thumbnails are ~20KB each, 2000 items ≈ 40MB max memory
|
|
from collections import OrderedDict
|
|
|
|
_thumb_cache: OrderedDict = OrderedDict() # storage_id -> (etag, bytes)
|
|
_thumb_cache_lock = Lock()
|
|
_THUMB_CACHE_MAX = 2000
|
|
|
|
|
|
def _thumb_cache_get(storage_id: str):
|
|
"""Get a thumbnail from cache. Returns (etag, bytes) or None."""
|
|
with _thumb_cache_lock:
|
|
item = _thumb_cache.get(storage_id)
|
|
if item:
|
|
_thumb_cache.move_to_end(storage_id)
|
|
return item
|
|
|
|
|
|
def _thumb_cache_put(storage_id: str, etag: str, data: bytes):
|
|
"""Put a thumbnail into cache, evicting oldest if full."""
|
|
with _thumb_cache_lock:
|
|
if storage_id in _thumb_cache:
|
|
_thumb_cache.move_to_end(storage_id)
|
|
else:
|
|
if len(_thumb_cache) >= _THUMB_CACHE_MAX:
|
|
_thumb_cache.popitem(last=False)
|
|
_thumb_cache[storage_id] = (etag, data)
|
|
|
|
|
|
def _thumb_cache_invalidate(storage_id: str):
|
|
"""Remove a specific thumbnail from cache."""
|
|
with _thumb_cache_lock:
|
|
_thumb_cache.pop(storage_id, None)
|
|
|
|
|
|
# ============================================================================
|
|
# JOB TRACKING FOR BACKGROUND PROCESSING
|
|
# ============================================================================
|
|
|
|
_pg_jobs: Dict[str, Dict] = {}
|
|
_pg_jobs_lock = Lock()
|
|
|
|
|
|
def _create_pg_job(job_id: str, total_files: int, operation: str):
|
|
"""Create a new private gallery job."""
|
|
with _pg_jobs_lock:
|
|
_pg_jobs[job_id] = {
|
|
'id': job_id,
|
|
'status': 'processing',
|
|
'operation': operation,
|
|
'total_files': total_files,
|
|
'processed_files': 0,
|
|
'success_count': 0,
|
|
'failed_count': 0,
|
|
'duplicate_count': 0,
|
|
'results': [],
|
|
'current_file': None,
|
|
'current_phase': None,
|
|
'bytes_downloaded': 0,
|
|
'bytes_total': 0,
|
|
'started_at': datetime.now().isoformat(),
|
|
'completed_at': None
|
|
}
|
|
|
|
|
|
def _update_pg_job(job_id: str, updates: Dict):
|
|
"""Update a private gallery job's status."""
|
|
with _pg_jobs_lock:
|
|
if job_id in _pg_jobs:
|
|
_pg_jobs[job_id].update(updates)
|
|
|
|
|
|
def _get_pg_job(job_id: str) -> Optional[Dict]:
|
|
"""Get the current status of a private gallery job."""
|
|
with _pg_jobs_lock:
|
|
job = _pg_jobs.get(job_id)
|
|
return dict(job) if job else None
|
|
|
|
|
|
def _cleanup_old_pg_jobs():
|
|
"""Remove jobs older than 1 hour."""
|
|
with _pg_jobs_lock:
|
|
now = datetime.now()
|
|
to_remove = []
|
|
for job_id, job in _pg_jobs.items():
|
|
if job.get('completed_at'):
|
|
try:
|
|
completed = datetime.fromisoformat(job['completed_at'])
|
|
if (now - completed).total_seconds() > 3600:
|
|
to_remove.append(job_id)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
for job_id in to_remove:
|
|
del _pg_jobs[job_id]
|
|
|
|
|
|
# ============================================================================
|
|
# PYDANTIC MODELS
|
|
# ============================================================================
|
|
|
|
class SetupRequest(BaseModel):
|
|
password: str = Field(..., min_length=8)
|
|
|
|
|
|
class UnlockRequest(BaseModel):
|
|
password: str
|
|
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
current_password: str
|
|
new_password: str = Field(..., min_length=8)
|
|
|
|
|
|
class ConfigUpdateRequest(BaseModel):
|
|
storage_path: Optional[str] = None
|
|
thumbnail_path: Optional[str] = None
|
|
organize_by_person: Optional[bool] = None
|
|
organize_by_date: Optional[bool] = None
|
|
auto_lock_minutes: Optional[int] = None
|
|
duplicate_auto_select_distance: Optional[int] = Field(None, ge=0, le=12)
|
|
min_import_resolution: Optional[int] = Field(None, ge=0, le=10000)
|
|
|
|
|
|
class RelationshipRequest(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=50)
|
|
color: Optional[str] = "#6366f1"
|
|
|
|
|
|
class PersonRequest(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
sort_name: Optional[str] = None
|
|
relationship_id: int
|
|
default_tag_ids: Optional[List[int]] = None
|
|
|
|
|
|
class PersonUpdateRequest(BaseModel):
|
|
name: Optional[str] = None
|
|
sort_name: Optional[str] = None
|
|
relationship_id: Optional[int] = None
|
|
default_tag_ids: Optional[List[int]] = None
|
|
|
|
|
|
class MediaUpdateRequest(BaseModel):
|
|
description: Optional[str] = None
|
|
person_id: Optional[int] = None
|
|
media_date: Optional[str] = None
|
|
tag_ids: Optional[List[int]] = None
|
|
|
|
|
|
class PostUpdateRequest(BaseModel):
|
|
description: Optional[str] = None
|
|
person_id: Optional[int] = None
|
|
media_date: Optional[str] = None
|
|
tag_ids: Optional[List[int]] = None
|
|
|
|
|
|
class AttachMediaRequest(BaseModel):
|
|
media_ids: List[int]
|
|
|
|
|
|
class CopyRequest(BaseModel):
|
|
source_paths: List[str]
|
|
person_id: int
|
|
tag_ids: List[int] = []
|
|
media_date: Optional[str] = None
|
|
description: Optional[str] = None
|
|
original_filenames: Optional[Dict[str, str]] = None # source_path -> original filename
|
|
|
|
|
|
class ImportUrlRequest(BaseModel):
|
|
urls: List[str]
|
|
person_id: int
|
|
tag_ids: List[int] = []
|
|
media_date: Optional[str] = None
|
|
description: Optional[str] = None
|
|
|
|
|
|
class ImportDirectoryRequest(BaseModel):
|
|
directory_path: str
|
|
person_id: int
|
|
tag_ids: List[int] = []
|
|
media_date: Optional[str] = None
|
|
description: Optional[str] = None
|
|
recursive: bool = False
|
|
|
|
|
|
class FeaturesUpdateRequest(BaseModel):
|
|
"""Request body for updating enabled features."""
|
|
enabled_features: List[str] = Field(..., description="List of enabled feature paths")
|
|
feature_order: Optional[Dict[str, List[str]]] = Field(None, description="Order of features by group")
|
|
feature_labels: Optional[Dict[str, str]] = Field(None, description="Custom labels for features: {'/path': 'Custom Label'}")
|
|
group_order: Optional[List[str]] = Field(None, description="Order of groups: ['media', 'video', ...]")
|
|
|
|
|
|
class BatchDeleteRequest(BaseModel):
|
|
media_ids: List[int]
|
|
|
|
|
|
class BatchTagsRequest(BaseModel):
|
|
media_ids: List[int]
|
|
add_tag_ids: Optional[List[int]] = None
|
|
remove_tag_ids: Optional[List[int]] = None
|
|
|
|
|
|
class BatchReadStatusRequest(BaseModel):
|
|
post_ids: List[int]
|
|
is_read: bool
|
|
|
|
|
|
class BatchDateRequest(BaseModel):
|
|
media_ids: List[int]
|
|
new_date: str
|
|
|
|
|
|
class BatchPersonRequest(BaseModel):
|
|
media_ids: List[int]
|
|
person_id: int
|
|
|
|
|
|
class ExportBatchRequest(BaseModel):
|
|
media_ids: List[int]
|
|
organize_by_date: Optional[bool] = False
|
|
organize_by_person: Optional[bool] = False
|
|
|
|
|
|
class ExportAllRequest(BaseModel):
|
|
organize_by_date: Optional[bool] = True
|
|
organize_by_person: Optional[bool] = True
|
|
include_metadata: Optional[bool] = True
|
|
|
|
|
|
class PostCreateRequest(BaseModel):
|
|
person_id: int
|
|
tag_ids: Optional[List[int]] = None
|
|
media_date: Optional[str] = None
|
|
description: Optional[str] = None
|
|
media_ids: Optional[List[int]] = None
|
|
|
|
|
|
class TagCreateRequest(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
color: Optional[str] = '#6b7280'
|
|
description: Optional[str] = None
|
|
|
|
|
|
class TagUpdateRequest(BaseModel):
|
|
name: Optional[str] = None
|
|
color: Optional[str] = None
|
|
description: Optional[str] = None
|
|
|
|
|
|
class ImportAuthCreateRequest(BaseModel):
|
|
domain: str = Field(..., min_length=1, max_length=253)
|
|
auth_type: str = Field(default='basic', pattern='^(basic|cookies|both)$')
|
|
username: Optional[str] = None
|
|
password: Optional[str] = None
|
|
cookies: Optional[List[dict]] = None
|
|
user_agent: Optional[str] = None
|
|
notes: Optional[str] = None
|
|
|
|
|
|
class ImportAuthUpdateRequest(BaseModel):
|
|
domain: Optional[str] = None
|
|
auth_type: Optional[str] = Field(default=None, pattern='^(basic|cookies|both)$')
|
|
username: Optional[str] = None
|
|
password: Optional[str] = None
|
|
cookies: Optional[List[dict]] = None
|
|
user_agent: Optional[str] = None
|
|
notes: Optional[str] = None
|
|
|
|
|
|
class PersonGroupCreate(BaseModel):
|
|
name: str
|
|
description: Optional[str] = None
|
|
min_resolution: Optional[int] = Field(0, ge=0, le=10000)
|
|
|
|
class PersonGroupUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
min_resolution: Optional[int] = Field(None, ge=0, le=10000)
|
|
|
|
class PersonGroupMemberAdd(BaseModel):
|
|
person_id: int
|
|
|
|
class PersonGroupTagMemberAdd(BaseModel):
|
|
tag_id: int
|
|
|
|
class PersonGroupRelationshipMemberAdd(BaseModel):
|
|
relationship_id: int
|
|
|
|
class PersonGroupExcludedTagAdd(BaseModel):
|
|
tag_id: int
|
|
|
|
|
|
class RedditCommunityCreate(BaseModel):
|
|
subreddit_name: str
|
|
person_id: int
|
|
|
|
class RedditCommunityUpdate(BaseModel):
|
|
subreddit_name: Optional[str] = None
|
|
person_id: Optional[int] = None
|
|
enabled: Optional[bool] = None
|
|
|
|
class RedditMonitorSettingsUpdate(BaseModel):
|
|
enabled: Optional[bool] = None
|
|
check_interval_hours: Optional[int] = None
|
|
lookback_days: Optional[int] = None
|
|
|
|
class RedditCookiesUpload(BaseModel):
|
|
cookies_json: str # JSON string of cookies array
|
|
|
|
|
|
class ScraperAccountCreate(BaseModel):
|
|
username: str
|
|
person_id: int
|
|
|
|
class ScraperAccountUpdate(BaseModel):
|
|
person_id: Optional[int] = None
|
|
enabled: Optional[bool] = None
|
|
|
|
|
|
# ============================================================================
|
|
# HELPER FUNCTIONS
|
|
# ============================================================================
|
|
|
|
def _get_crypto():
|
|
"""Get the crypto instance."""
|
|
return get_private_gallery_crypto()
|
|
|
|
|
|
def _get_db():
|
|
"""Get the unified database instance."""
|
|
app_state = get_app_state()
|
|
return app_state.db
|
|
|
|
|
|
def _get_config(db) -> Dict[str, Any]:
|
|
"""Get private gallery configuration (cached for 30s)."""
|
|
global _config_cache_time
|
|
now = time.monotonic()
|
|
with _config_cache_lock:
|
|
if _config_cache and (now - _config_cache_time) < _CONFIG_CACHE_TTL:
|
|
return dict(_config_cache)
|
|
config = {}
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT key, value FROM private_media_config')
|
|
for row in cursor.fetchall():
|
|
value = row['value']
|
|
# Convert string booleans and numbers
|
|
if value is None:
|
|
pass
|
|
elif value == 'true':
|
|
value = True
|
|
elif value == 'false':
|
|
value = False
|
|
elif value.isdigit():
|
|
value = int(value)
|
|
config[row['key']] = value
|
|
with _config_cache_lock:
|
|
_config_cache.clear()
|
|
_config_cache.update(config)
|
|
_config_cache_time = now
|
|
return config
|
|
|
|
|
|
def _get_import_auth_for_url(db, crypto, url: str) -> Optional[Dict]:
|
|
"""Find matching import auth for a URL. Suffix matching, most-specific first."""
|
|
from urllib.parse import urlparse
|
|
try:
|
|
parsed = urlparse(url)
|
|
hostname = (parsed.hostname or '').lower().strip('.')
|
|
except Exception:
|
|
return None
|
|
|
|
if not hostname:
|
|
return None
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT * FROM private_gallery_import_auth ORDER BY LENGTH(domain) DESC')
|
|
rows = cursor.fetchall()
|
|
|
|
for row in rows:
|
|
domain = row['domain']
|
|
if hostname == domain or hostname.endswith('.' + domain):
|
|
result = {
|
|
'auth_type': row['auth_type'],
|
|
}
|
|
if row['encrypted_username']:
|
|
result['username'] = crypto.decrypt_field(row['encrypted_username'])
|
|
if row['encrypted_password']:
|
|
result['password'] = crypto.decrypt_field(row['encrypted_password'])
|
|
if row['encrypted_cookies_json']:
|
|
try:
|
|
result['cookies'] = json.loads(crypto.decrypt_field(row['encrypted_cookies_json']))
|
|
except (json.JSONDecodeError, Exception) as e:
|
|
logger.debug(f"Error decrypting cookies: {e}", module="PrivateGallery")
|
|
if row['encrypted_user_agent']:
|
|
result['user_agent'] = crypto.decrypt_field(row['encrypted_user_agent'])
|
|
return result
|
|
|
|
return None
|
|
|
|
|
|
def _set_config(db, key: str, value: Any) -> None:
|
|
"""Set a configuration value."""
|
|
if isinstance(value, bool):
|
|
value = 'true' if value else 'false'
|
|
else:
|
|
value = str(value)
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
INSERT OR REPLACE INTO private_media_config (key, value, updated_at)
|
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
''', (key, value))
|
|
conn.commit()
|
|
_invalidate_config_cache()
|
|
|
|
|
|
def _verify_gallery_token(
|
|
token: str = Header(None, alias=PRIVATE_GALLERY_TOKEN_HEADER),
|
|
_token: str = Query(None, description="Token via query param for img/video tags")
|
|
) -> Dict:
|
|
"""Verify the private gallery session token.
|
|
|
|
Accepts token from either header (for API calls) or query param (for img/video tags).
|
|
Refreshes session expiry on each valid request (activity-based timeout).
|
|
"""
|
|
# Use header token first, fall back to query param
|
|
actual_token = token or _token
|
|
|
|
if not actual_token:
|
|
raise AuthError("Private gallery authentication required")
|
|
|
|
crypto = _get_crypto()
|
|
session = crypto.verify_session(actual_token)
|
|
|
|
if not session:
|
|
raise AuthError("Invalid or expired session token")
|
|
|
|
if not crypto.is_initialized():
|
|
raise AuthError("Gallery is locked")
|
|
|
|
# Refresh session expiry on activity
|
|
db = _get_db()
|
|
config = _get_config(db)
|
|
auto_lock = config.get('auto_lock_minutes', 30)
|
|
crypto.refresh_session(actual_token, auto_lock)
|
|
|
|
return session
|
|
|
|
|
|
def _get_file_hash(file_path: Path) -> str:
|
|
"""Calculate SHA256 hash of a file."""
|
|
sha256 = hashlib.sha256()
|
|
with open(file_path, 'rb') as f:
|
|
for chunk in iter(lambda: f.read(65536), b''):
|
|
sha256.update(chunk)
|
|
return sha256.hexdigest()
|
|
|
|
|
|
def _compute_perceptual_hash(file_path: Path) -> Optional[str]:
|
|
"""Calculate perceptual hash (dHash) for an image or video file."""
|
|
try:
|
|
import imagehash
|
|
from PIL import Image
|
|
except ImportError:
|
|
return None
|
|
|
|
ext = file_path.suffix.lower().lstrip('.')
|
|
image_exts = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'heic', 'heif', 'avif'}
|
|
video_exts = {'mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv', 'flv'}
|
|
|
|
pil_image = None
|
|
frame = None
|
|
frame_rgb = None
|
|
|
|
try:
|
|
if ext in video_exts:
|
|
try:
|
|
import cv2
|
|
except ImportError:
|
|
return None
|
|
cap = cv2.VideoCapture(str(file_path))
|
|
if not cap.isOpened():
|
|
return None
|
|
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
cap.set(cv2.CAP_PROP_POS_FRAMES, int(total_frames * 0.5))
|
|
ret, frame = cap.read()
|
|
cap.release()
|
|
if not ret or frame is None:
|
|
return None
|
|
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
pil_image = Image.fromarray(frame_rgb)
|
|
elif ext in image_exts:
|
|
pil_image = Image.open(file_path)
|
|
else:
|
|
return None
|
|
|
|
phash = str(imagehash.dhash(pil_image, hash_size=16))
|
|
return phash
|
|
except Exception:
|
|
return None
|
|
finally:
|
|
if pil_image is not None:
|
|
pil_image.close()
|
|
del pil_image
|
|
if frame_rgb is not None:
|
|
del frame_rgb
|
|
if frame is not None:
|
|
del frame
|
|
|
|
|
|
def _get_file_info(file_path: Path) -> Dict[str, Any]:
|
|
"""Get file type, mime type, and dimensions."""
|
|
ext = file_path.suffix.lower().lstrip('.')
|
|
mime_type, _ = mimetypes.guess_type(str(file_path))
|
|
if not mime_type:
|
|
mime_type = 'application/octet-stream'
|
|
|
|
image_exts = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'heic', 'heif', 'avif'}
|
|
video_exts = {'mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv', 'flv'}
|
|
|
|
if ext in image_exts:
|
|
file_type = 'image'
|
|
elif ext in video_exts:
|
|
file_type = 'video'
|
|
else:
|
|
file_type = 'other'
|
|
|
|
info = {
|
|
'file_type': file_type,
|
|
'mime_type': mime_type,
|
|
'width': None,
|
|
'height': None,
|
|
'duration': None
|
|
}
|
|
|
|
# Get dimensions for images
|
|
if file_type == 'image':
|
|
try:
|
|
from PIL import Image
|
|
with Image.open(file_path) as img:
|
|
info['width'], info['height'] = img.size
|
|
except Exception:
|
|
pass
|
|
|
|
# Get duration/dimensions for videos
|
|
if file_type == 'video':
|
|
try:
|
|
import subprocess
|
|
result = subprocess.run([
|
|
'ffprobe', '-v', 'quiet', '-print_format', 'json',
|
|
'-show_streams', '-show_format', str(file_path)
|
|
], capture_output=True, text=True, timeout=30)
|
|
if result.returncode == 0:
|
|
import json
|
|
data = json.loads(result.stdout)
|
|
for stream in data.get('streams', []):
|
|
if stream.get('codec_type') == 'video':
|
|
info['width'] = stream.get('width')
|
|
info['height'] = stream.get('height')
|
|
break
|
|
if 'format' in data:
|
|
duration = data['format'].get('duration')
|
|
if duration:
|
|
info['duration'] = float(duration)
|
|
except Exception:
|
|
pass
|
|
|
|
return info
|
|
|
|
|
|
def _generate_thumbnail(file_path: Path, output_path: Path, file_type: str) -> bool:
|
|
"""Generate a thumbnail for an image or video."""
|
|
try:
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if file_type == 'image':
|
|
from PIL import Image, ImageOps
|
|
with Image.open(file_path) as img:
|
|
# Apply EXIF orientation before resizing
|
|
img = ImageOps.exif_transpose(img)
|
|
img.thumbnail((400, 400))
|
|
# Convert to RGB if necessary (for JPEG)
|
|
if img.mode in ('RGBA', 'P'):
|
|
img = img.convert('RGB')
|
|
img.save(output_path, 'JPEG', quality=85)
|
|
return True
|
|
|
|
elif file_type == 'video':
|
|
import subprocess
|
|
result = subprocess.run([
|
|
'ffmpeg', '-y', '-i', str(file_path),
|
|
'-ss', '00:00:01', '-vframes', '1',
|
|
'-vf', 'scale=400:-1:force_original_aspect_ratio=decrease',
|
|
str(output_path)
|
|
], capture_output=True, timeout=30)
|
|
return result.returncode == 0 and output_path.exists()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Thumbnail generation failed: {e}")
|
|
return False
|
|
|
|
|
|
def _extract_date_from_filename(filename: str) -> Optional[str]:
|
|
"""Extract date and optionally time from filename patterns."""
|
|
# Patterns with date and time (6 groups: year, month, day, hour, minute, second)
|
|
patterns_with_time = [
|
|
# IMG_20260115_143022.jpg or video_20260115_143022.mp4
|
|
r'(?:IMG|VID|video|photo)?[-_]?(\d{4})(\d{2})(\d{2})[-_](\d{2})(\d{2})(\d{2})',
|
|
# 2026-01-15_14-30-22.jpg or 2026_01_15_14_30_22.jpg
|
|
r'(\d{4})[-_](\d{2})[-_](\d{2})[-_](\d{2})[-_](\d{2})[-_](\d{2})',
|
|
# 20260115143022.jpg (all digits)
|
|
r'(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})',
|
|
]
|
|
|
|
# Patterns with date only (3 groups: year, month, day)
|
|
patterns_date_only = [
|
|
r'(\d{4})[-_]?(\d{2})[-_]?(\d{2})', # 2026-01-15 or 20260115
|
|
r'IMG[-_]?(\d{4})(\d{2})(\d{2})', # IMG_20260115
|
|
r'(\d{2})[-_](\d{2})[-_](\d{4})', # 15-01-2026
|
|
]
|
|
|
|
# First try patterns with time
|
|
for pattern in patterns_with_time:
|
|
match = re.search(pattern, filename)
|
|
if match:
|
|
groups = match.groups()
|
|
try:
|
|
year, month, day = int(groups[0]), int(groups[1]), int(groups[2])
|
|
hour, minute, second = int(groups[3]), int(groups[4]), int(groups[5])
|
|
|
|
if (1 <= month <= 12 and 1 <= day <= 31 and 2000 <= year <= 2100 and
|
|
0 <= hour <= 23 and 0 <= minute <= 59 and 0 <= second <= 59):
|
|
return f"{year:04d}-{month:02d}-{day:02d}T{hour:02d}:{minute:02d}:{second:02d}"
|
|
except (ValueError, IndexError):
|
|
continue
|
|
|
|
# Fall back to date-only patterns
|
|
for pattern in patterns_date_only:
|
|
match = re.search(pattern, filename)
|
|
if match:
|
|
groups = match.groups()
|
|
try:
|
|
if len(groups[0]) == 4:
|
|
year, month, day = int(groups[0]), int(groups[1]), int(groups[2])
|
|
else:
|
|
day, month, year = int(groups[0]), int(groups[1]), int(groups[2])
|
|
|
|
if 1 <= month <= 12 and 1 <= day <= 31 and 2000 <= year <= 2100:
|
|
return f"{year:04d}-{month:02d}-{day:02d}"
|
|
except (ValueError, IndexError):
|
|
continue
|
|
|
|
return None
|
|
|
|
|
|
def _extract_date_from_exif(file_path: Path) -> Optional[str]:
|
|
"""Extract date and time from EXIF metadata."""
|
|
try:
|
|
from PIL import Image
|
|
from PIL.ExifTags import TAGS
|
|
|
|
with Image.open(file_path) as img:
|
|
exif = img._getexif()
|
|
if exif:
|
|
for tag_id, value in exif.items():
|
|
tag = TAGS.get(tag_id, tag_id)
|
|
if tag in ('DateTimeOriginal', 'DateTime', 'DateTimeDigitized'):
|
|
if value:
|
|
# Format: "2026:01:15 14:30:22"
|
|
parts = value.split(' ')
|
|
date_part = parts[0].replace(':', '-')
|
|
if len(parts) > 1 and parts[1]:
|
|
return f"{date_part}T{parts[1]}"
|
|
return date_part
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ============================================================================
|
|
# JOB STATUS ENDPOINT
|
|
# ============================================================================
|
|
|
|
@router.get("/job-status/{job_id}")
|
|
@limiter.limit("120/minute")
|
|
@handle_exceptions
|
|
async def get_gallery_job_status(
|
|
request: Request,
|
|
job_id: str,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get the status of a private gallery background job."""
|
|
_cleanup_old_pg_jobs()
|
|
job = _get_pg_job(job_id)
|
|
|
|
if not job:
|
|
raise NotFoundError(f"Job '{job_id}' not found")
|
|
|
|
return job
|
|
|
|
|
|
# ============================================================================
|
|
# AUTHENTICATION ENDPOINTS (No gallery auth required)
|
|
# ============================================================================
|
|
|
|
@router.get("/status")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def get_status(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user)
|
|
):
|
|
"""Check if setup is complete and current lock status."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
config = _get_config(db)
|
|
|
|
# Check if the user has a valid session token (not just if crypto is initialized)
|
|
is_unlocked = False
|
|
token = request.headers.get('X-Private-Gallery-Token')
|
|
if token and crypto.is_initialized():
|
|
session = crypto.verify_session(token)
|
|
is_unlocked = session is not None
|
|
# Refresh session expiry on status check (keeps session alive during active use)
|
|
if is_unlocked:
|
|
auto_lock = config.get('auto_lock_minutes', 30)
|
|
crypto.refresh_session(token, auto_lock)
|
|
|
|
return {
|
|
"is_setup_complete": config.get('is_setup_complete', False),
|
|
"is_unlocked": is_unlocked,
|
|
"active_sessions": crypto.get_active_session_count()
|
|
}
|
|
|
|
|
|
@router.post("/setup")
|
|
@limiter.limit("5/minute")
|
|
@handle_exceptions
|
|
async def setup_gallery(
|
|
request: Request,
|
|
body: SetupRequest,
|
|
current_user: Dict = Depends(get_current_user)
|
|
):
|
|
"""First-time setup - create password and encryption salt."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
config = _get_config(db)
|
|
|
|
if config.get('is_setup_complete', False):
|
|
raise ValidationError("Gallery is already set up")
|
|
|
|
# Hash password
|
|
password_hash = crypto.hash_password(body.password)
|
|
_set_config(db, 'password_hash', password_hash)
|
|
|
|
# Generate and store encryption salt
|
|
salt = crypto.generate_salt()
|
|
import base64
|
|
_set_config(db, 'encryption_salt', base64.b64encode(salt).decode('utf-8'))
|
|
|
|
# Create default relationship types
|
|
relationships = [
|
|
('Friend', '#10b981'),
|
|
('Family', '#3b82f6'),
|
|
('Coworker', '#f59e0b'),
|
|
('Acquaintance', '#8b5cf6'),
|
|
('Other', '#6b7280')
|
|
]
|
|
|
|
# Initialize encryption to encrypt relationship names
|
|
crypto.initialize_encryption(body.password, salt)
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
for name, color in relationships:
|
|
encrypted_name = crypto.encrypt_field(name)
|
|
cursor.execute('''
|
|
INSERT INTO private_media_relationships (encrypted_name, color)
|
|
VALUES (?, ?)
|
|
''', (encrypted_name, color))
|
|
conn.commit()
|
|
|
|
# Create storage directories
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
(storage_path / 'data').mkdir(parents=True, exist_ok=True)
|
|
(storage_path / 'thumbs').mkdir(parents=True, exist_ok=True)
|
|
|
|
# Mark setup complete
|
|
_set_config(db, 'is_setup_complete', True)
|
|
|
|
# Create session token
|
|
auto_lock = config.get('auto_lock_minutes', 30)
|
|
token = crypto.create_session(current_user.get('sub', 'user'), auto_lock)
|
|
|
|
logger.info("Private gallery setup completed")
|
|
return {
|
|
"message": "Gallery setup complete",
|
|
"token": token
|
|
}
|
|
|
|
|
|
@router.post("/unlock")
|
|
@limiter.limit("10/minute")
|
|
@handle_exceptions
|
|
async def unlock_gallery(
|
|
request: Request,
|
|
body: UnlockRequest,
|
|
current_user: Dict = Depends(get_current_user)
|
|
):
|
|
"""Unlock the gallery with password."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
config = _get_config(db)
|
|
|
|
if not config.get('is_setup_complete', False):
|
|
raise ValidationError("Gallery is not set up yet")
|
|
|
|
# Verify password (use 403 not 401 so frontend doesn't clear main auth session)
|
|
password_hash = config.get('password_hash', '')
|
|
if not crypto.verify_password(body.password, password_hash):
|
|
raise HTTPException(status_code=403, detail="Invalid password")
|
|
|
|
# Get salt and initialize encryption
|
|
import base64
|
|
salt = base64.b64decode(config.get('encryption_salt', ''))
|
|
crypto.initialize_encryption(body.password, salt)
|
|
|
|
# Create session token
|
|
auto_lock = config.get('auto_lock_minutes', 30)
|
|
token = crypto.create_session(current_user.get('sub', 'user'), auto_lock)
|
|
|
|
logger.info("Private gallery unlocked")
|
|
|
|
# Export key files for background services (Reddit monitor + scraper bridge)
|
|
try:
|
|
from modules.private_gallery_crypto import export_key_to_file
|
|
from modules.scraper_gallery_bridge import SCRAPER_BRIDGE_KEY_FILE
|
|
export_key_to_file(SCRAPER_BRIDGE_KEY_FILE)
|
|
except Exception:
|
|
pass
|
|
|
|
# One-time migration: convert single-shot encrypted files >50MB to chunked format
|
|
# for streaming playback. Runs once per API process on first unlock.
|
|
if getattr(unlock_gallery, '_migration_done', False):
|
|
return {"message": "Gallery unlocked", "token": token}
|
|
unlock_gallery._migration_done = True
|
|
|
|
def _auto_migrate_chunked():
|
|
try:
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
data_path = storage_path / 'data'
|
|
min_size = 50 * 1024 * 1024
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, storage_id, file_size FROM private_media WHERE file_size > ? ORDER BY file_size ASC', (min_size,))
|
|
rows = cursor.fetchall()
|
|
|
|
to_migrate = []
|
|
for row in rows:
|
|
enc_file = data_path / f"{row['storage_id']}.enc"
|
|
if enc_file.exists() and not crypto._is_chunked_format(enc_file):
|
|
to_migrate.append(row)
|
|
|
|
if not to_migrate:
|
|
return
|
|
|
|
logger.info(f"Auto-migrating {len(to_migrate)} files to chunked encryption format")
|
|
for row in to_migrate:
|
|
enc_file = data_path / f"{row['storage_id']}.enc"
|
|
try:
|
|
if crypto.re_encrypt_to_chunked(enc_file):
|
|
logger.info(f"Migrated ID {row['id']} ({row['file_size']/1e6:.0f}MB) to chunked format")
|
|
except Exception as e:
|
|
logger.error(f"Migration failed for ID {row['id']}: {e}")
|
|
logger.info("Chunked encryption migration complete")
|
|
except Exception as e:
|
|
logger.error(f"Auto-migration failed: {e}")
|
|
|
|
import threading
|
|
threading.Thread(target=_auto_migrate_chunked, daemon=True).start()
|
|
|
|
return {
|
|
"message": "Gallery unlocked",
|
|
"token": token
|
|
}
|
|
|
|
|
|
@router.post("/lock")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def lock_gallery(
|
|
request: Request,
|
|
token: str = Header(None, alias=PRIVATE_GALLERY_TOKEN_HEADER),
|
|
current_user: Dict = Depends(get_current_user)
|
|
):
|
|
"""Lock the gallery and invalidate session."""
|
|
crypto = _get_crypto()
|
|
|
|
if token:
|
|
crypto.invalidate_session(token)
|
|
|
|
crypto.invalidate_all_sessions()
|
|
crypto.clear_encryption()
|
|
|
|
logger.info("Private gallery locked")
|
|
return message_response("Gallery locked")
|
|
|
|
|
|
@router.post("/change-password")
|
|
@limiter.limit("5/minute")
|
|
@handle_exceptions
|
|
async def change_password(
|
|
request: Request,
|
|
body: ChangePasswordRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Change the gallery password."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
config = _get_config(db)
|
|
|
|
# Verify current password
|
|
password_hash = config.get('password_hash', '')
|
|
if not crypto.verify_password(body.current_password, password_hash):
|
|
raise AuthError("Current password is incorrect")
|
|
|
|
# Hash new password
|
|
new_hash = crypto.hash_password(body.new_password)
|
|
_set_config(db, 'password_hash', new_hash)
|
|
|
|
# Note: We keep the same salt to avoid re-encrypting all data
|
|
# The encryption key will be different when unlocked with new password
|
|
# This means existing encrypted data needs to be re-encrypted
|
|
|
|
# For simplicity in this implementation, we'll keep the salt the same
|
|
# In a production system, you might want to re-encrypt all data
|
|
|
|
logger.info("Private gallery password changed")
|
|
return message_response("Password changed successfully")
|
|
|
|
|
|
# ============================================================================
|
|
# CONFIGURATION ENDPOINTS
|
|
# ============================================================================
|
|
|
|
@router.get("/config")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def get_config(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get gallery configuration."""
|
|
db = _get_db()
|
|
config = _get_config(db)
|
|
|
|
# Remove sensitive fields
|
|
safe_config = {k: v for k, v in config.items()
|
|
if k not in ('password_hash', 'encryption_salt')}
|
|
|
|
return {"config": safe_config}
|
|
|
|
|
|
@router.put("/config")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def update_config(
|
|
request: Request,
|
|
body: ConfigUpdateRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Update gallery configuration."""
|
|
db = _get_db()
|
|
|
|
if body.storage_path is not None:
|
|
_set_config(db, 'storage_path', body.storage_path)
|
|
Path(body.storage_path).mkdir(parents=True, exist_ok=True)
|
|
|
|
if body.thumbnail_path is not None:
|
|
_set_config(db, 'thumbnail_path', body.thumbnail_path)
|
|
Path(body.thumbnail_path).mkdir(parents=True, exist_ok=True)
|
|
|
|
if body.organize_by_person is not None:
|
|
_set_config(db, 'organize_by_person', body.organize_by_person)
|
|
|
|
if body.organize_by_date is not None:
|
|
_set_config(db, 'organize_by_date', body.organize_by_date)
|
|
|
|
if body.auto_lock_minutes is not None:
|
|
_set_config(db, 'auto_lock_minutes', body.auto_lock_minutes)
|
|
|
|
if body.duplicate_auto_select_distance is not None:
|
|
_set_config(db, 'duplicate_auto_select_distance', body.duplicate_auto_select_distance)
|
|
|
|
if body.min_import_resolution is not None:
|
|
_set_config(db, 'min_import_resolution', body.min_import_resolution)
|
|
|
|
return message_response("Configuration updated")
|
|
|
|
|
|
# ============================================================================
|
|
# FEATURES ENDPOINTS
|
|
# ============================================================================
|
|
|
|
# Default list of all available features (all enabled by default)
|
|
# These paths must match App.tsx menu items
|
|
ALL_FEATURES = [
|
|
# Primary nav (Media section)
|
|
'/downloads',
|
|
'/gallery',
|
|
'/review',
|
|
# Video dropdown
|
|
'/videos',
|
|
'/celebrities',
|
|
'/queue',
|
|
'/video/channel-monitors',
|
|
# Tools dropdown
|
|
'/import',
|
|
'/faces',
|
|
'/scheduler',
|
|
'/appearances',
|
|
'/press',
|
|
'/discovery',
|
|
'/recycle-bin',
|
|
# Analytics dropdown
|
|
'/analytics',
|
|
'/health',
|
|
'/monitoring',
|
|
'/notifications',
|
|
# Paid Content dropdown
|
|
'/paid-content',
|
|
'/paid-content/feed',
|
|
'/paid-content/gallery',
|
|
'/paid-content/messages',
|
|
'/paid-content/creators',
|
|
'/paid-content/add',
|
|
'/paid-content/queue',
|
|
'/paid-content/notifications',
|
|
'/paid-content/settings',
|
|
'/paid-content/watch-later',
|
|
'/paid-content/analytics',
|
|
'/paid-content/recycle',
|
|
# Private Gallery
|
|
'/private-gallery',
|
|
'/private-gallery/config',
|
|
# System dropdown (always enabled - can't disable config)
|
|
'/config',
|
|
'/platforms',
|
|
'/scrapers',
|
|
'/logs',
|
|
'/changelog',
|
|
]
|
|
|
|
|
|
@router.get("/features")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def get_features(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get list of enabled features.
|
|
|
|
Returns both the list of all available features and which ones are enabled.
|
|
"""
|
|
import json
|
|
|
|
db = _get_db()
|
|
config = _get_config(db)
|
|
|
|
# Get enabled features from config, default to all features enabled
|
|
enabled_features_json = config.get('enabled_features')
|
|
if enabled_features_json and isinstance(enabled_features_json, str):
|
|
try:
|
|
enabled_features = json.loads(enabled_features_json)
|
|
# Auto-enable only truly new features (added to code after last save)
|
|
known_features_json = config.get('known_features')
|
|
known_features = []
|
|
if known_features_json and isinstance(known_features_json, str):
|
|
try:
|
|
known_features = json.loads(known_features_json)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
for feature in ALL_FEATURES:
|
|
if feature not in enabled_features and feature not in known_features:
|
|
enabled_features.append(feature)
|
|
# Filter out stale/removed features
|
|
enabled_features = [f for f in enabled_features if f in ALL_FEATURES]
|
|
except json.JSONDecodeError:
|
|
enabled_features = ALL_FEATURES.copy()
|
|
else:
|
|
enabled_features = ALL_FEATURES.copy()
|
|
|
|
# Get feature order from config
|
|
feature_order_json = config.get('feature_order')
|
|
if feature_order_json and isinstance(feature_order_json, str):
|
|
try:
|
|
feature_order = json.loads(feature_order_json)
|
|
except json.JSONDecodeError:
|
|
feature_order = {}
|
|
else:
|
|
feature_order = {}
|
|
|
|
# Get custom labels from config
|
|
feature_labels_json = config.get('feature_labels')
|
|
if feature_labels_json and isinstance(feature_labels_json, str):
|
|
try:
|
|
feature_labels = json.loads(feature_labels_json)
|
|
except json.JSONDecodeError:
|
|
feature_labels = {}
|
|
else:
|
|
feature_labels = {}
|
|
|
|
# Get group order from config
|
|
group_order_json = config.get('group_order')
|
|
if group_order_json and isinstance(group_order_json, str):
|
|
try:
|
|
group_order = json.loads(group_order_json)
|
|
except json.JSONDecodeError:
|
|
group_order = []
|
|
else:
|
|
group_order = []
|
|
|
|
return {
|
|
"all_features": ALL_FEATURES,
|
|
"enabled_features": enabled_features,
|
|
"feature_order": feature_order,
|
|
"feature_labels": feature_labels,
|
|
"group_order": group_order
|
|
}
|
|
|
|
|
|
@router.put("/features")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def update_features(
|
|
request: Request,
|
|
body: FeaturesUpdateRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Update the list of enabled features.
|
|
|
|
Only features in the ALL_FEATURES list can be enabled/disabled.
|
|
System features like /config cannot be disabled.
|
|
"""
|
|
import json
|
|
|
|
db = _get_db()
|
|
|
|
# Filter out any stale/removed features silently
|
|
valid_features = [f for f in body.enabled_features if f in ALL_FEATURES]
|
|
|
|
# Always ensure /config is enabled (can't lock yourself out)
|
|
# Use dict.fromkeys() to deduplicate while preserving order
|
|
enabled = list(dict.fromkeys(valid_features))
|
|
if '/config' not in enabled:
|
|
enabled.append('/config')
|
|
|
|
# Store enabled features as JSON string
|
|
_set_config(db, 'enabled_features', json.dumps(enabled))
|
|
|
|
# Store the set of known features at save time so we can detect truly new features later
|
|
_set_config(db, 'known_features', json.dumps(ALL_FEATURES.copy()))
|
|
|
|
# Store feature order if provided
|
|
feature_order = {}
|
|
if body.feature_order:
|
|
feature_order = body.feature_order
|
|
_set_config(db, 'feature_order', json.dumps(feature_order))
|
|
|
|
# Store custom labels if provided
|
|
feature_labels = {}
|
|
if body.feature_labels:
|
|
feature_labels = body.feature_labels
|
|
_set_config(db, 'feature_labels', json.dumps(feature_labels))
|
|
|
|
# Store group order if provided
|
|
group_order = []
|
|
if body.group_order:
|
|
group_order = body.group_order
|
|
_set_config(db, 'group_order', json.dumps(group_order))
|
|
|
|
return {
|
|
"message": "Features updated",
|
|
"enabled_features": enabled,
|
|
"feature_order": feature_order,
|
|
"feature_labels": feature_labels,
|
|
"group_order": group_order
|
|
}
|
|
|
|
|
|
@router.get("/features/public")
|
|
@limiter.limit("120/minute")
|
|
@handle_exceptions
|
|
async def get_features_public(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user)
|
|
):
|
|
"""Get enabled features without requiring private gallery auth.
|
|
|
|
This endpoint is used by the frontend to determine which menu items to show.
|
|
It doesn't expose the full feature list, just which ones are enabled.
|
|
"""
|
|
import json
|
|
|
|
db = _get_db()
|
|
config = _get_config(db)
|
|
|
|
# Get enabled features from config, default to all features enabled
|
|
enabled_features_json = config.get('enabled_features')
|
|
if enabled_features_json and isinstance(enabled_features_json, str):
|
|
try:
|
|
enabled_features = json.loads(enabled_features_json)
|
|
# Auto-enable only truly new features (added to code after last save)
|
|
known_features_json = config.get('known_features')
|
|
known_features = []
|
|
if known_features_json and isinstance(known_features_json, str):
|
|
try:
|
|
known_features = json.loads(known_features_json)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
for feature in ALL_FEATURES:
|
|
if feature not in enabled_features and feature not in known_features:
|
|
enabled_features.append(feature)
|
|
# Filter out stale/removed features
|
|
enabled_features = [f for f in enabled_features if f in ALL_FEATURES]
|
|
except json.JSONDecodeError:
|
|
enabled_features = ALL_FEATURES.copy()
|
|
else:
|
|
enabled_features = ALL_FEATURES.copy()
|
|
|
|
# Get feature order from config
|
|
feature_order_json = config.get('feature_order')
|
|
if feature_order_json and isinstance(feature_order_json, str):
|
|
try:
|
|
feature_order = json.loads(feature_order_json)
|
|
except json.JSONDecodeError:
|
|
feature_order = {}
|
|
else:
|
|
feature_order = {}
|
|
|
|
# Get custom labels from config
|
|
feature_labels_json = config.get('feature_labels')
|
|
if feature_labels_json and isinstance(feature_labels_json, str):
|
|
try:
|
|
feature_labels = json.loads(feature_labels_json)
|
|
except json.JSONDecodeError:
|
|
feature_labels = {}
|
|
else:
|
|
feature_labels = {}
|
|
|
|
# Get group order from config
|
|
group_order_json = config.get('group_order')
|
|
if group_order_json and isinstance(group_order_json, str):
|
|
try:
|
|
group_order = json.loads(group_order_json)
|
|
except json.JSONDecodeError:
|
|
group_order = []
|
|
else:
|
|
group_order = []
|
|
|
|
return {
|
|
"enabled_features": enabled_features,
|
|
"feature_order": feature_order,
|
|
"feature_labels": feature_labels,
|
|
"group_order": group_order
|
|
}
|
|
|
|
|
|
@router.post("/features/reset")
|
|
@limiter.limit("10/minute")
|
|
@handle_exceptions
|
|
async def reset_features(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Reset all feature settings to defaults.
|
|
|
|
This enables all features, clears custom labels, and resets ordering.
|
|
"""
|
|
import json
|
|
|
|
db = _get_db()
|
|
|
|
# Reset to all features enabled
|
|
_set_config(db, 'enabled_features', json.dumps(ALL_FEATURES))
|
|
# Clear custom order
|
|
_set_config(db, 'feature_order', json.dumps({}))
|
|
# Clear custom labels
|
|
_set_config(db, 'feature_labels', json.dumps({}))
|
|
# Clear group order
|
|
_set_config(db, 'group_order', json.dumps([]))
|
|
|
|
return {
|
|
"message": "Features reset to defaults",
|
|
"enabled_features": ALL_FEATURES,
|
|
"feature_order": {},
|
|
"feature_labels": {},
|
|
"group_order": []
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# RELATIONSHIP ENDPOINTS
|
|
# ============================================================================
|
|
|
|
@router.get("/relationships")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def get_relationships(
|
|
request: Request,
|
|
assigned_only: Optional[bool] = None,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get all relationship types. If assigned_only=true, only return relationships that have persons assigned."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
if assigned_only:
|
|
cursor.execute('''
|
|
SELECT DISTINCT r.id, r.encrypted_name, r.color, r.created_at
|
|
FROM private_media_relationships r
|
|
WHERE r.id IN (
|
|
SELECT relationship_id FROM private_media_persons
|
|
)
|
|
ORDER BY r.id
|
|
''')
|
|
else:
|
|
cursor.execute('''
|
|
SELECT id, encrypted_name, color, created_at
|
|
FROM private_media_relationships
|
|
ORDER BY id
|
|
''')
|
|
rows = cursor.fetchall()
|
|
|
|
relationships = []
|
|
for row in rows:
|
|
relationships.append({
|
|
'id': row['id'],
|
|
'name': crypto.decrypt_field(row['encrypted_name']),
|
|
'color': row['color'],
|
|
'created_at': row['created_at']
|
|
})
|
|
|
|
relationships.sort(key=lambda r: r['name'].lower())
|
|
|
|
return {"relationships": relationships}
|
|
|
|
|
|
@router.post("/relationships")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def create_relationship(
|
|
request: Request,
|
|
body: RelationshipRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Create a new relationship type."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
encrypted_name = crypto.encrypt_field(body.name)
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
INSERT INTO private_media_relationships (encrypted_name, color)
|
|
VALUES (?, ?)
|
|
''', (encrypted_name, body.color))
|
|
rel_id = cursor.lastrowid
|
|
conn.commit()
|
|
|
|
return {
|
|
"id": rel_id,
|
|
"name": body.name,
|
|
"color": body.color
|
|
}
|
|
|
|
|
|
@router.put("/relationships/{relationship_id}")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def update_relationship(
|
|
request: Request,
|
|
relationship_id: int,
|
|
body: RelationshipRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Update a relationship type."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
encrypted_name = crypto.encrypt_field(body.name)
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
UPDATE private_media_relationships
|
|
SET encrypted_name = ?, color = ?
|
|
WHERE id = ?
|
|
''', (encrypted_name, body.color, relationship_id))
|
|
if cursor.rowcount == 0:
|
|
raise NotFoundError(f"Relationship {relationship_id} not found")
|
|
conn.commit()
|
|
|
|
return message_response("Relationship updated")
|
|
|
|
|
|
@router.delete("/relationships/{relationship_id}")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def delete_relationship(
|
|
request: Request,
|
|
relationship_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Delete a relationship type (fails if persons exist)."""
|
|
db = _get_db()
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Check if any persons use this relationship
|
|
cursor.execute('''
|
|
SELECT COUNT(*) as count FROM private_media_persons
|
|
WHERE relationship_id = ?
|
|
''', (relationship_id,))
|
|
if cursor.fetchone()['count'] > 0:
|
|
raise ValidationError("Cannot delete relationship type that has persons assigned")
|
|
|
|
cursor.execute('DELETE FROM private_media_relationships WHERE id = ?', (relationship_id,))
|
|
if cursor.rowcount == 0:
|
|
raise NotFoundError(f"Relationship {relationship_id} not found")
|
|
conn.commit()
|
|
|
|
return message_response("Relationship deleted")
|
|
|
|
|
|
# ============================================================================
|
|
# PERSON ENDPOINTS
|
|
# ============================================================================
|
|
|
|
@router.get("/persons")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def get_persons(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get all persons with relationship info."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
# Ensure default tags table exists (migration safety)
|
|
with db.get_connection(for_write=True) as conn:
|
|
conn.cursor().execute('''
|
|
CREATE TABLE IF NOT EXISTS private_media_person_default_tags (
|
|
person_id INTEGER NOT NULL, tag_id INTEGER NOT NULL,
|
|
PRIMARY KEY (person_id, tag_id),
|
|
FOREIGN KEY (person_id) REFERENCES private_media_persons(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (tag_id) REFERENCES private_gallery_tags(id) ON DELETE CASCADE
|
|
)
|
|
''')
|
|
conn.commit()
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT p.id, p.encrypted_name, p.encrypted_sort_name,
|
|
p.relationship_id, p.created_at, p.updated_at,
|
|
r.encrypted_name as rel_encrypted_name, r.color as rel_color
|
|
FROM private_media_persons p
|
|
JOIN private_media_relationships r ON p.relationship_id = r.id
|
|
ORDER BY p.encrypted_sort_name, p.encrypted_name
|
|
''')
|
|
rows = cursor.fetchall()
|
|
|
|
# Batch-query default tags for all persons
|
|
cursor.execute('SELECT person_id, tag_id FROM private_media_person_default_tags')
|
|
default_tags_rows = cursor.fetchall()
|
|
|
|
# Build person_id -> [tag_ids] map
|
|
default_tags_map: Dict[int, List[int]] = {}
|
|
for dt_row in default_tags_rows:
|
|
pid = dt_row['person_id']
|
|
if pid not in default_tags_map:
|
|
default_tags_map[pid] = []
|
|
default_tags_map[pid].append(dt_row['tag_id'])
|
|
|
|
persons = []
|
|
for row in rows:
|
|
persons.append({
|
|
'id': row['id'],
|
|
'name': crypto.decrypt_field(row['encrypted_name']),
|
|
'sort_name': crypto.decrypt_field(row['encrypted_sort_name']) if row['encrypted_sort_name'] else None,
|
|
'relationship_id': row['relationship_id'],
|
|
'relationship': {
|
|
'id': row['relationship_id'],
|
|
'name': crypto.decrypt_field(row['rel_encrypted_name']),
|
|
'color': row['rel_color']
|
|
},
|
|
'default_tag_ids': default_tags_map.get(row['id'], []),
|
|
'created_at': row['created_at'],
|
|
'updated_at': row['updated_at']
|
|
})
|
|
|
|
# Sort by decrypted name
|
|
persons.sort(key=lambda p: (p.get('sort_name') or p['name']).lower())
|
|
|
|
return {"persons": persons}
|
|
|
|
|
|
@router.post("/persons")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def create_person(
|
|
request: Request,
|
|
body: PersonRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Create a new person."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
encrypted_name = crypto.encrypt_field(body.name)
|
|
encrypted_sort_name = crypto.encrypt_field(body.sort_name) if body.sort_name else None
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Verify relationship exists
|
|
cursor.execute('SELECT id FROM private_media_relationships WHERE id = ?', (body.relationship_id,))
|
|
if not cursor.fetchone():
|
|
raise ValidationError(f"Relationship {body.relationship_id} not found")
|
|
|
|
cursor.execute('''
|
|
INSERT INTO private_media_persons (encrypted_name, encrypted_sort_name, relationship_id)
|
|
VALUES (?, ?, ?)
|
|
''', (encrypted_name, encrypted_sort_name, body.relationship_id))
|
|
person_id = cursor.lastrowid
|
|
|
|
# Insert default tags
|
|
if body.default_tag_ids:
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS private_media_person_default_tags (
|
|
person_id INTEGER NOT NULL, tag_id INTEGER NOT NULL,
|
|
PRIMARY KEY (person_id, tag_id),
|
|
FOREIGN KEY (person_id) REFERENCES private_media_persons(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (tag_id) REFERENCES private_gallery_tags(id) ON DELETE CASCADE
|
|
)
|
|
''')
|
|
for tag_id in body.default_tag_ids:
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO private_media_person_default_tags (person_id, tag_id)
|
|
VALUES (?, ?)
|
|
''', (person_id, tag_id))
|
|
|
|
conn.commit()
|
|
|
|
return {
|
|
"id": person_id,
|
|
"name": body.name,
|
|
"sort_name": body.sort_name,
|
|
"relationship_id": body.relationship_id,
|
|
"default_tag_ids": body.default_tag_ids or []
|
|
}
|
|
|
|
|
|
@router.put("/persons/{person_id}")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def update_person(
|
|
request: Request,
|
|
person_id: int,
|
|
body: PersonUpdateRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Update a person."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
updates = []
|
|
params = []
|
|
|
|
if body.name is not None:
|
|
updates.append("encrypted_name = ?")
|
|
params.append(crypto.encrypt_field(body.name))
|
|
|
|
if body.sort_name is not None:
|
|
updates.append("encrypted_sort_name = ?")
|
|
params.append(crypto.encrypt_field(body.sort_name) if body.sort_name else None)
|
|
|
|
if body.relationship_id is not None:
|
|
updates.append("relationship_id = ?")
|
|
params.append(body.relationship_id)
|
|
|
|
if not updates and body.default_tag_ids is None:
|
|
raise ValidationError("No fields to update")
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
if updates:
|
|
updates.append("updated_at = CURRENT_TIMESTAMP")
|
|
params.append(person_id)
|
|
cursor.execute(f'''
|
|
UPDATE private_media_persons
|
|
SET {", ".join(updates)}
|
|
WHERE id = ?
|
|
''', params)
|
|
if cursor.rowcount == 0:
|
|
raise NotFoundError(f"Person {person_id} not found")
|
|
|
|
# Sync default tags (delete-and-reinsert)
|
|
if body.default_tag_ids is not None:
|
|
# Ensure table exists (migration safety)
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS private_media_person_default_tags (
|
|
person_id INTEGER NOT NULL, tag_id INTEGER NOT NULL,
|
|
PRIMARY KEY (person_id, tag_id),
|
|
FOREIGN KEY (person_id) REFERENCES private_media_persons(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (tag_id) REFERENCES private_gallery_tags(id) ON DELETE CASCADE
|
|
)
|
|
''')
|
|
cursor.execute('DELETE FROM private_media_person_default_tags WHERE person_id = ?', (person_id,))
|
|
for tag_id in body.default_tag_ids:
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO private_media_person_default_tags (person_id, tag_id)
|
|
VALUES (?, ?)
|
|
''', (person_id, tag_id))
|
|
|
|
# Apply default tags to all existing posts for this person
|
|
cursor.execute('SELECT id FROM private_media_posts WHERE person_id = ?', (person_id,))
|
|
post_ids = [row['id'] for row in cursor.fetchall()]
|
|
for post_id in post_ids:
|
|
for tag_id in body.default_tag_ids:
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO private_media_post_tags (post_id, tag_id)
|
|
VALUES (?, ?)
|
|
''', (post_id, tag_id))
|
|
|
|
conn.commit()
|
|
|
|
return message_response("Person updated")
|
|
|
|
|
|
@router.delete("/persons/{person_id}")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def delete_person(
|
|
request: Request,
|
|
person_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Delete a person (media will have person_id set to NULL)."""
|
|
db = _get_db()
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('DELETE FROM private_media_persons WHERE id = ?', (person_id,))
|
|
if cursor.rowcount == 0:
|
|
raise NotFoundError(f"Person {person_id} not found")
|
|
conn.commit()
|
|
|
|
return message_response("Person deleted")
|
|
|
|
|
|
# ============================================================================
|
|
# PERSON GROUP ENDPOINTS (Encrypted)
|
|
# ============================================================================
|
|
|
|
@router.get("/person-groups")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def list_person_groups(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""List all person groups with member counts."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT g.id, g.encrypted_name, g.encrypted_description, g.created_at, g.updated_at,
|
|
g.min_resolution,
|
|
(SELECT COUNT(*) FROM private_media_person_group_members WHERE group_id = g.id) as member_count,
|
|
(SELECT COUNT(*) FROM private_media_person_group_tag_members WHERE group_id = g.id) as tag_member_count,
|
|
(SELECT COUNT(*) FROM private_media_person_group_relationship_members WHERE group_id = g.id) as relationship_member_count,
|
|
(SELECT COUNT(*) FROM private_media_person_group_excluded_tags WHERE group_id = g.id) as excluded_tag_count
|
|
FROM private_media_person_groups g
|
|
ORDER BY g.created_at DESC
|
|
''')
|
|
rows = cursor.fetchall()
|
|
|
|
groups = []
|
|
for row in rows:
|
|
groups.append({
|
|
'id': row['id'],
|
|
'name': crypto.decrypt_field(row['encrypted_name']),
|
|
'description': crypto.decrypt_field(row['encrypted_description']) if row['encrypted_description'] else None,
|
|
'member_count': row['member_count'],
|
|
'tag_member_count': row['tag_member_count'],
|
|
'relationship_member_count': row['relationship_member_count'],
|
|
'excluded_tag_count': row['excluded_tag_count'],
|
|
'min_resolution': row['min_resolution'] or 0,
|
|
'created_at': row['created_at'],
|
|
'updated_at': row['updated_at'],
|
|
})
|
|
|
|
groups.sort(key=lambda g: g['name'].lower())
|
|
|
|
return {"groups": groups}
|
|
|
|
|
|
@router.get("/person-groups/{group_id}")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def get_person_group(
|
|
request: Request,
|
|
group_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get a single person group with its members."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT id, encrypted_name, encrypted_description, min_resolution, created_at, updated_at
|
|
FROM private_media_person_groups WHERE id = ?
|
|
''', (group_id,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
raise NotFoundError(f"Person group {group_id} not found")
|
|
|
|
group = {
|
|
'id': row['id'],
|
|
'name': crypto.decrypt_field(row['encrypted_name']),
|
|
'description': crypto.decrypt_field(row['encrypted_description']) if row['encrypted_description'] else None,
|
|
'min_resolution': row['min_resolution'] or 0,
|
|
'created_at': row['created_at'],
|
|
'updated_at': row['updated_at'],
|
|
}
|
|
|
|
# Fetch person members
|
|
cursor.execute('''
|
|
SELECT p.id, p.encrypted_name, p.encrypted_sort_name, p.relationship_id,
|
|
r.encrypted_name as rel_encrypted_name, r.color as rel_color,
|
|
m.added_at
|
|
FROM private_media_person_group_members m
|
|
JOIN private_media_persons p ON m.person_id = p.id
|
|
JOIN private_media_relationships r ON p.relationship_id = r.id
|
|
WHERE m.group_id = ?
|
|
ORDER BY p.encrypted_name
|
|
''', (group_id,))
|
|
member_rows = cursor.fetchall()
|
|
|
|
# Fetch tag members
|
|
cursor.execute('''
|
|
SELECT t.id, t.encrypted_name, t.color, tm.added_at
|
|
FROM private_media_person_group_tag_members tm
|
|
JOIN private_gallery_tags t ON tm.tag_id = t.id
|
|
WHERE tm.group_id = ?
|
|
''', (group_id,))
|
|
tag_member_rows = cursor.fetchall()
|
|
|
|
# Fetch relationship members
|
|
cursor.execute('''
|
|
SELECT r.id, r.encrypted_name, r.color, rm.added_at
|
|
FROM private_media_person_group_relationship_members rm
|
|
JOIN private_media_relationships r ON rm.relationship_id = r.id
|
|
WHERE rm.group_id = ?
|
|
''', (group_id,))
|
|
rel_member_rows = cursor.fetchall()
|
|
|
|
# Fetch excluded tags
|
|
cursor.execute('''
|
|
SELECT t.id, t.encrypted_name, t.color, et.added_at
|
|
FROM private_media_person_group_excluded_tags et
|
|
JOIN private_gallery_tags t ON et.tag_id = t.id
|
|
WHERE et.group_id = ?
|
|
''', (group_id,))
|
|
excluded_tag_rows = cursor.fetchall()
|
|
|
|
members = []
|
|
for mr in member_rows:
|
|
members.append({
|
|
'id': mr['id'],
|
|
'name': crypto.decrypt_field(mr['encrypted_name']),
|
|
'sort_name': crypto.decrypt_field(mr['encrypted_sort_name']) if mr['encrypted_sort_name'] else None,
|
|
'relationship_id': mr['relationship_id'],
|
|
'relationship': {
|
|
'id': mr['relationship_id'],
|
|
'name': crypto.decrypt_field(mr['rel_encrypted_name']),
|
|
'color': mr['rel_color'],
|
|
},
|
|
'added_at': mr['added_at'],
|
|
})
|
|
members.sort(key=lambda m: (m.get('sort_name') or m['name']).lower())
|
|
|
|
tag_members = []
|
|
for tr in tag_member_rows:
|
|
tag_members.append({
|
|
'id': tr['id'],
|
|
'name': crypto.decrypt_field(tr['encrypted_name']),
|
|
'color': tr['color'],
|
|
'added_at': tr['added_at'],
|
|
})
|
|
tag_members.sort(key=lambda t: t['name'].lower())
|
|
|
|
relationship_members = []
|
|
for rr in rel_member_rows:
|
|
relationship_members.append({
|
|
'id': rr['id'],
|
|
'name': crypto.decrypt_field(rr['encrypted_name']),
|
|
'color': rr['color'],
|
|
'added_at': rr['added_at'],
|
|
})
|
|
relationship_members.sort(key=lambda r: r['name'].lower())
|
|
|
|
excluded_tags = []
|
|
for etr in excluded_tag_rows:
|
|
excluded_tags.append({
|
|
'id': etr['id'],
|
|
'name': crypto.decrypt_field(etr['encrypted_name']),
|
|
'color': etr['color'],
|
|
'added_at': etr['added_at'],
|
|
})
|
|
excluded_tags.sort(key=lambda t: t['name'].lower())
|
|
|
|
group['members'] = members
|
|
group['member_count'] = len(members)
|
|
group['tag_members'] = tag_members
|
|
group['tag_member_count'] = len(tag_members)
|
|
group['relationship_members'] = relationship_members
|
|
group['relationship_member_count'] = len(relationship_members)
|
|
group['excluded_tags'] = excluded_tags
|
|
group['excluded_tag_count'] = len(excluded_tags)
|
|
|
|
return group
|
|
|
|
|
|
@router.post("/person-groups")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def create_person_group(
|
|
request: Request,
|
|
body: PersonGroupCreate,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Create a new person group."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
encrypted_name = crypto.encrypt_field(body.name)
|
|
encrypted_description = crypto.encrypt_field(body.description) if body.description else None
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
INSERT INTO private_media_person_groups (encrypted_name, encrypted_description, min_resolution)
|
|
VALUES (?, ?, ?)
|
|
''', (encrypted_name, encrypted_description, body.min_resolution or 0))
|
|
group_id = cursor.lastrowid
|
|
conn.commit()
|
|
|
|
return {
|
|
"group": {
|
|
"id": group_id,
|
|
"name": body.name,
|
|
"description": body.description,
|
|
"min_resolution": body.min_resolution or 0,
|
|
"member_count": 0,
|
|
},
|
|
"message": "Group created"
|
|
}
|
|
|
|
|
|
@router.put("/person-groups/{group_id}")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def update_person_group(
|
|
request: Request,
|
|
group_id: int,
|
|
body: PersonGroupUpdate,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Update a person group."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
# Check existence
|
|
cursor.execute('SELECT id FROM private_media_person_groups WHERE id = ?', (group_id,))
|
|
if not cursor.fetchone():
|
|
raise NotFoundError(f"Person group {group_id} not found")
|
|
|
|
updates = []
|
|
params = []
|
|
if body.name is not None:
|
|
updates.append("encrypted_name = ?")
|
|
params.append(crypto.encrypt_field(body.name))
|
|
if body.description is not None:
|
|
updates.append("encrypted_description = ?")
|
|
params.append(crypto.encrypt_field(body.description))
|
|
if body.min_resolution is not None:
|
|
updates.append("min_resolution = ?")
|
|
params.append(body.min_resolution)
|
|
|
|
if updates:
|
|
updates.append("updated_at = CURRENT_TIMESTAMP")
|
|
params.append(group_id)
|
|
cursor.execute(f'UPDATE private_media_person_groups SET {", ".join(updates)} WHERE id = ?', params)
|
|
conn.commit()
|
|
|
|
return {"success": True, "message": "Group updated"}
|
|
|
|
|
|
@router.delete("/person-groups/{group_id}")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def delete_person_group(
|
|
request: Request,
|
|
group_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Delete a person group."""
|
|
db = _get_db()
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('DELETE FROM private_media_person_groups WHERE id = ?', (group_id,))
|
|
if cursor.rowcount == 0:
|
|
raise NotFoundError(f"Person group {group_id} not found")
|
|
conn.commit()
|
|
|
|
return {"success": True, "message": "Group deleted"}
|
|
|
|
|
|
@router.post("/person-groups/{group_id}/members")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def add_person_to_group(
|
|
request: Request,
|
|
group_id: int,
|
|
body: PersonGroupMemberAdd,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Add a person to a group."""
|
|
db = _get_db()
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
# Verify group exists
|
|
cursor.execute('SELECT id FROM private_media_person_groups WHERE id = ?', (group_id,))
|
|
if not cursor.fetchone():
|
|
raise NotFoundError(f"Person group {group_id} not found")
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO private_media_person_group_members (group_id, person_id)
|
|
VALUES (?, ?)
|
|
''', (group_id, body.person_id))
|
|
conn.commit()
|
|
|
|
return {"success": True, "message": "Person added to group"}
|
|
|
|
|
|
@router.delete("/person-groups/{group_id}/members/{person_id}")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def remove_person_from_group(
|
|
request: Request,
|
|
group_id: int,
|
|
person_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Remove a person from a group."""
|
|
db = _get_db()
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('DELETE FROM private_media_person_group_members WHERE group_id = ? AND person_id = ?', (group_id, person_id))
|
|
conn.commit()
|
|
|
|
return {"success": True, "message": "Person removed from group"}
|
|
|
|
|
|
@router.post("/person-groups/{group_id}/tags")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def add_tag_to_person_group(
|
|
request: Request,
|
|
group_id: int,
|
|
body: PersonGroupTagMemberAdd,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Add a tag to a person group."""
|
|
db = _get_db()
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id FROM private_media_person_groups WHERE id = ?', (group_id,))
|
|
if not cursor.fetchone():
|
|
raise NotFoundError(f"Person group {group_id} not found")
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO private_media_person_group_tag_members (group_id, tag_id)
|
|
VALUES (?, ?)
|
|
''', (group_id, body.tag_id))
|
|
conn.commit()
|
|
|
|
return {"success": True, "message": "Tag added to group"}
|
|
|
|
|
|
@router.delete("/person-groups/{group_id}/tags/{tag_id}")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def remove_tag_from_person_group(
|
|
request: Request,
|
|
group_id: int,
|
|
tag_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Remove a tag from a person group."""
|
|
db = _get_db()
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('DELETE FROM private_media_person_group_tag_members WHERE group_id = ? AND tag_id = ?', (group_id, tag_id))
|
|
conn.commit()
|
|
|
|
return {"success": True, "message": "Tag removed from group"}
|
|
|
|
|
|
@router.post("/person-groups/{group_id}/relationships")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def add_relationship_to_person_group(
|
|
request: Request,
|
|
group_id: int,
|
|
body: PersonGroupRelationshipMemberAdd,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Add a relationship to a person group."""
|
|
db = _get_db()
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id FROM private_media_person_groups WHERE id = ?', (group_id,))
|
|
if not cursor.fetchone():
|
|
raise NotFoundError(f"Person group {group_id} not found")
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO private_media_person_group_relationship_members (group_id, relationship_id)
|
|
VALUES (?, ?)
|
|
''', (group_id, body.relationship_id))
|
|
conn.commit()
|
|
|
|
return {"success": True, "message": "Relationship added to group"}
|
|
|
|
|
|
@router.delete("/person-groups/{group_id}/relationships/{relationship_id}")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def remove_relationship_from_person_group(
|
|
request: Request,
|
|
group_id: int,
|
|
relationship_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Remove a relationship from a person group."""
|
|
db = _get_db()
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('DELETE FROM private_media_person_group_relationship_members WHERE group_id = ? AND relationship_id = ?', (group_id, relationship_id))
|
|
conn.commit()
|
|
|
|
return {"success": True, "message": "Relationship removed from group"}
|
|
|
|
|
|
@router.post("/person-groups/{group_id}/excluded-tags")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def add_excluded_tag_to_person_group(
|
|
request: Request,
|
|
group_id: int,
|
|
body: PersonGroupExcludedTagAdd,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Add an excluded tag to a person group."""
|
|
db = _get_db()
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id FROM private_media_person_groups WHERE id = ?', (group_id,))
|
|
if not cursor.fetchone():
|
|
raise NotFoundError(f"Person group {group_id} not found")
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO private_media_person_group_excluded_tags (group_id, tag_id)
|
|
VALUES (?, ?)
|
|
''', (group_id, body.tag_id))
|
|
conn.commit()
|
|
|
|
return {"success": True, "message": "Excluded tag added to group"}
|
|
|
|
|
|
@router.delete("/person-groups/{group_id}/excluded-tags/{tag_id}")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def remove_excluded_tag_from_person_group(
|
|
request: Request,
|
|
group_id: int,
|
|
tag_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Remove an excluded tag from a person group."""
|
|
db = _get_db()
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('DELETE FROM private_media_person_group_excluded_tags WHERE group_id = ? AND tag_id = ?', (group_id, tag_id))
|
|
conn.commit()
|
|
|
|
return {"success": True, "message": "Excluded tag removed from group"}
|
|
|
|
|
|
# ============================================================================
|
|
# TAG ENDPOINTS (Encrypted private_gallery_tags)
|
|
# ============================================================================
|
|
|
|
_tags_migrated = False
|
|
|
|
def _ensure_private_gallery_tags_migrated(db, crypto):
|
|
"""Sync all tags from paid_content_tags to private_gallery_tags, preserving IDs."""
|
|
global _tags_migrated
|
|
if _tags_migrated:
|
|
return
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Create the table if it doesn't exist
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS private_gallery_tags (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
encrypted_name TEXT NOT NULL,
|
|
color TEXT DEFAULT '#6b7280',
|
|
encrypted_description TEXT,
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
''')
|
|
|
|
# Get existing private gallery tag IDs
|
|
cursor.execute('SELECT id FROM private_gallery_tags')
|
|
existing_ids = {row['id'] for row in cursor.fetchall()}
|
|
|
|
# Copy ALL paid content tags that don't already exist in private gallery
|
|
cursor.execute('SELECT id, name, color, description FROM paid_content_tags')
|
|
new_count = 0
|
|
for row in cursor.fetchall():
|
|
if row['id'] not in existing_ids:
|
|
encrypted_name = crypto.encrypt_field(row['name'])
|
|
encrypted_description = crypto.encrypt_field(row['description']) if row['description'] else None
|
|
cursor.execute('''
|
|
INSERT INTO private_gallery_tags (id, encrypted_name, color, encrypted_description)
|
|
VALUES (?, ?, ?, ?)
|
|
''', (row['id'], encrypted_name, row['color'], encrypted_description))
|
|
new_count += 1
|
|
|
|
if new_count > 0:
|
|
conn.commit()
|
|
logger.info(f"Synced {new_count} new tags from paid_content_tags to private_gallery_tags")
|
|
|
|
_tags_migrated = True
|
|
|
|
|
|
@router.get("/tags")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def get_tags(
|
|
request: Request,
|
|
assigned_only: Optional[bool] = None,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get all encrypted tags. If assigned_only=true, only return tags used in private gallery."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
# Ensure migration has run
|
|
_ensure_private_gallery_tags_migrated(db, crypto)
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
if assigned_only:
|
|
cursor.execute('''
|
|
SELECT t.id, t.encrypted_name, t.color, t.encrypted_description, t.created_at,
|
|
(SELECT COUNT(*) FROM private_media_tags mt WHERE mt.tag_id = t.id) +
|
|
(SELECT COUNT(*) FROM private_media_post_tags pt WHERE pt.tag_id = t.id) as usage_count
|
|
FROM private_gallery_tags t
|
|
WHERE t.id IN (
|
|
SELECT tag_id FROM private_media_tags
|
|
UNION
|
|
SELECT tag_id FROM private_media_post_tags
|
|
)
|
|
''')
|
|
else:
|
|
cursor.execute('''
|
|
SELECT id, encrypted_name, color, encrypted_description, created_at,
|
|
(SELECT COUNT(*) FROM private_media_tags mt WHERE mt.tag_id = private_gallery_tags.id) +
|
|
(SELECT COUNT(*) FROM private_media_post_tags pt WHERE pt.tag_id = private_gallery_tags.id) as usage_count
|
|
FROM private_gallery_tags
|
|
''')
|
|
rows = cursor.fetchall()
|
|
|
|
tags = []
|
|
for row in rows:
|
|
tags.append({
|
|
'id': row['id'],
|
|
'name': crypto.decrypt_field(row['encrypted_name']),
|
|
'color': row['color'],
|
|
'description': crypto.decrypt_field(row['encrypted_description']) if row['encrypted_description'] else None,
|
|
'created_at': row['created_at'],
|
|
'usage_count': row['usage_count'],
|
|
})
|
|
|
|
# Sort alphabetically by name
|
|
tags.sort(key=lambda t: t['name'].lower())
|
|
|
|
return {"tags": tags}
|
|
|
|
|
|
@router.post("/tags")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def create_tag(
|
|
request: Request,
|
|
body: TagCreateRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Create a new encrypted tag."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
encrypted_name = crypto.encrypt_field(body.name)
|
|
encrypted_description = crypto.encrypt_field(body.description) if body.description else None
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
INSERT INTO private_gallery_tags (encrypted_name, color, encrypted_description)
|
|
VALUES (?, ?, ?)
|
|
''', (encrypted_name, body.color, encrypted_description))
|
|
tag_id = cursor.lastrowid
|
|
conn.commit()
|
|
|
|
return {
|
|
"id": tag_id,
|
|
"name": body.name,
|
|
"color": body.color,
|
|
"description": body.description,
|
|
}
|
|
|
|
|
|
@router.put("/tags/{tag_id}")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def update_tag(
|
|
request: Request,
|
|
tag_id: int,
|
|
body: TagUpdateRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Update an encrypted tag."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
updates = []
|
|
params = []
|
|
|
|
if body.name is not None:
|
|
updates.append("encrypted_name = ?")
|
|
params.append(crypto.encrypt_field(body.name))
|
|
|
|
if body.color is not None:
|
|
updates.append("color = ?")
|
|
params.append(body.color)
|
|
|
|
if body.description is not None:
|
|
updates.append("encrypted_description = ?")
|
|
params.append(crypto.encrypt_field(body.description) if body.description else None)
|
|
|
|
if not updates:
|
|
raise ValidationError("No fields to update")
|
|
|
|
params.append(tag_id)
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(f'''
|
|
UPDATE private_gallery_tags
|
|
SET {", ".join(updates)}
|
|
WHERE id = ?
|
|
''', params)
|
|
if cursor.rowcount == 0:
|
|
raise NotFoundError(f"Tag {tag_id} not found")
|
|
conn.commit()
|
|
|
|
return message_response("Tag updated")
|
|
|
|
|
|
@router.delete("/tags/{tag_id}")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def delete_tag(
|
|
request: Request,
|
|
tag_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Delete an encrypted tag. Junction table CASCADE handles cleanup."""
|
|
db = _get_db()
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('DELETE FROM private_gallery_tags WHERE id = ?', (tag_id,))
|
|
if cursor.rowcount == 0:
|
|
raise NotFoundError(f"Tag {tag_id} not found")
|
|
conn.commit()
|
|
|
|
return message_response("Tag deleted")
|
|
|
|
|
|
# ============================================================================
|
|
# IMPORT AUTH ENDPOINTS
|
|
# ============================================================================
|
|
|
|
def _normalize_domain(domain: str) -> str:
|
|
"""Normalize domain: strip protocol/path, lowercase, strip leading dots."""
|
|
domain = domain.strip().lower()
|
|
# Strip protocol if present
|
|
if '://' in domain:
|
|
domain = domain.split('://', 1)[1]
|
|
# Strip path/query
|
|
domain = domain.split('/')[0].split('?')[0].split('#')[0]
|
|
# Strip port
|
|
domain = domain.split(':')[0]
|
|
# Strip leading/trailing dots
|
|
domain = domain.strip('.')
|
|
return domain
|
|
|
|
|
|
@router.get("/import-auth")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def get_import_auth(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""List all import auth entries. Never exposes decrypted credentials."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT * FROM private_gallery_import_auth ORDER BY domain')
|
|
rows = cursor.fetchall()
|
|
|
|
entries = []
|
|
for row in rows:
|
|
cookies_count = 0
|
|
if row['encrypted_cookies_json']:
|
|
try:
|
|
cookies = json.loads(crypto.decrypt_field(row['encrypted_cookies_json']))
|
|
cookies_count = len(cookies) if isinstance(cookies, list) else 0
|
|
except Exception:
|
|
pass
|
|
|
|
has_user_agent = bool(row['encrypted_user_agent'])
|
|
user_agent_display = None
|
|
if has_user_agent:
|
|
try:
|
|
ua = crypto.decrypt_field(row['encrypted_user_agent'])
|
|
user_agent_display = ua[:50] + '...' if len(ua) > 50 else ua
|
|
except Exception:
|
|
pass
|
|
|
|
entries.append({
|
|
'id': row['id'],
|
|
'domain': row['domain'],
|
|
'auth_type': row['auth_type'],
|
|
'has_username': bool(row['encrypted_username']),
|
|
'has_password': bool(row['encrypted_password']),
|
|
'cookies_count': cookies_count,
|
|
'user_agent': user_agent_display,
|
|
'notes': row['notes'],
|
|
'created_at': row['created_at'],
|
|
'updated_at': row['updated_at'],
|
|
})
|
|
|
|
return {"entries": entries}
|
|
|
|
|
|
@router.post("/import-auth")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def create_import_auth(
|
|
request: Request,
|
|
body: ImportAuthCreateRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Create a new import auth entry."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
domain = _normalize_domain(body.domain)
|
|
if not domain:
|
|
raise ValidationError("Invalid domain")
|
|
|
|
# Validate auth_type requirements
|
|
if body.auth_type in ('basic', 'both') and (not body.username or not body.password):
|
|
raise ValidationError("Username and password are required for basic/both auth type")
|
|
if body.auth_type in ('cookies', 'both') and not body.cookies:
|
|
raise ValidationError("Cookies are required for cookies/both auth type")
|
|
|
|
encrypted_username = crypto.encrypt_field(body.username) if body.username else None
|
|
encrypted_password = crypto.encrypt_field(body.password) if body.password else None
|
|
encrypted_cookies_json = crypto.encrypt_field(json.dumps(body.cookies)) if body.cookies else None
|
|
encrypted_user_agent = crypto.encrypt_field(body.user_agent) if body.user_agent else None
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
try:
|
|
cursor.execute('''
|
|
INSERT INTO private_gallery_import_auth
|
|
(domain, auth_type, encrypted_username, encrypted_password, encrypted_cookies_json, encrypted_user_agent, notes)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
''', (domain, body.auth_type, encrypted_username, encrypted_password, encrypted_cookies_json, encrypted_user_agent, body.notes))
|
|
entry_id = cursor.lastrowid
|
|
conn.commit()
|
|
except Exception as e:
|
|
if 'UNIQUE' in str(e).upper():
|
|
raise ValidationError(f"Domain '{domain}' already has an auth entry")
|
|
raise
|
|
|
|
return {"id": entry_id, "domain": domain, "auth_type": body.auth_type}
|
|
|
|
|
|
@router.put("/import-auth/{entry_id}")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def update_import_auth(
|
|
request: Request,
|
|
entry_id: int,
|
|
body: ImportAuthUpdateRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Update an import auth entry."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
updates = []
|
|
params = []
|
|
|
|
if body.domain is not None:
|
|
domain = _normalize_domain(body.domain)
|
|
if not domain:
|
|
raise ValidationError("Invalid domain")
|
|
updates.append("domain = ?")
|
|
params.append(domain)
|
|
|
|
if body.auth_type is not None:
|
|
updates.append("auth_type = ?")
|
|
params.append(body.auth_type)
|
|
|
|
if body.username is not None:
|
|
updates.append("encrypted_username = ?")
|
|
params.append(crypto.encrypt_field(body.username) if body.username else None)
|
|
|
|
if body.password is not None:
|
|
updates.append("encrypted_password = ?")
|
|
params.append(crypto.encrypt_field(body.password) if body.password else None)
|
|
|
|
if body.cookies is not None:
|
|
updates.append("encrypted_cookies_json = ?")
|
|
params.append(crypto.encrypt_field(json.dumps(body.cookies)) if body.cookies else None)
|
|
|
|
if body.user_agent is not None:
|
|
updates.append("encrypted_user_agent = ?")
|
|
params.append(crypto.encrypt_field(body.user_agent) if body.user_agent else None)
|
|
|
|
if body.notes is not None:
|
|
updates.append("notes = ?")
|
|
params.append(body.notes)
|
|
|
|
if not updates:
|
|
raise ValidationError("No fields to update")
|
|
|
|
updates.append("updated_at = CURRENT_TIMESTAMP")
|
|
params.append(entry_id)
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(f'''
|
|
UPDATE private_gallery_import_auth
|
|
SET {", ".join(updates)}
|
|
WHERE id = ?
|
|
''', params)
|
|
if cursor.rowcount == 0:
|
|
raise NotFoundError(f"Import auth entry {entry_id} not found")
|
|
conn.commit()
|
|
|
|
return message_response("Import auth updated")
|
|
|
|
|
|
@router.delete("/import-auth/{entry_id}")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def delete_import_auth(
|
|
request: Request,
|
|
entry_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Delete an import auth entry."""
|
|
db = _get_db()
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('DELETE FROM private_gallery_import_auth WHERE id = ?', (entry_id,))
|
|
if cursor.rowcount == 0:
|
|
raise NotFoundError(f"Import auth entry {entry_id} not found")
|
|
conn.commit()
|
|
|
|
return message_response("Import auth deleted")
|
|
|
|
|
|
# ============================================================================
|
|
# MEDIA ENDPOINTS
|
|
# ============================================================================
|
|
|
|
@router.get("/media")
|
|
@limiter.limit("120/minute")
|
|
@handle_exceptions
|
|
async def get_media(
|
|
request: Request,
|
|
offset: int = Query(0, ge=0),
|
|
limit: int = Query(50, ge=1, le=500),
|
|
person_id: Optional[int] = None,
|
|
person_group_id: Optional[int] = None,
|
|
relationship_id: Optional[int] = None,
|
|
tag_ids: Optional[str] = None,
|
|
file_type: Optional[str] = None,
|
|
search: Optional[str] = None,
|
|
sort_by: str = Query("media_date", regex="^(media_date|created_at|filename)$"),
|
|
sort_order: str = Query("desc", regex="^(asc|desc)$"),
|
|
date_from: Optional[str] = None,
|
|
date_to: Optional[str] = None,
|
|
has_tags: Optional[bool] = None,
|
|
has_description: Optional[bool] = None,
|
|
include_attached: Optional[bool] = None,
|
|
shuffle: Optional[bool] = None,
|
|
shuffle_seed: Optional[int] = None,
|
|
unread_only: Optional[bool] = None,
|
|
min_resolution: Optional[int] = Query(None, ge=0, le=10000),
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get media items with filtering and pagination."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
# Ensure private_gallery_tags table exists and is migrated
|
|
_ensure_private_gallery_tags_migrated(db, crypto)
|
|
|
|
# Build query - we fetch all then filter in Python since many fields are encrypted
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Build WHERE clauses for non-encrypted fields
|
|
where_clauses = []
|
|
params = []
|
|
|
|
# include_attached parameter is no longer used — media always appears
|
|
# in the gallery regardless of being attached to another post
|
|
|
|
if person_id is not None:
|
|
where_clauses.append("m.person_id = ?")
|
|
params.append(person_id)
|
|
|
|
if person_group_id is not None:
|
|
# WHO filter: persons + relationships (pass through if none defined)
|
|
where_clauses.append("""(
|
|
(
|
|
NOT EXISTS (SELECT 1 FROM private_media_person_group_members WHERE group_id = ?)
|
|
AND NOT EXISTS (SELECT 1 FROM private_media_person_group_relationship_members WHERE group_id = ?)
|
|
)
|
|
OR m.person_id IN (SELECT person_id FROM private_media_person_group_members WHERE group_id = ?)
|
|
OR m.person_id IN (SELECT p2.id FROM private_media_persons p2 WHERE p2.relationship_id IN (SELECT relationship_id FROM private_media_person_group_relationship_members WHERE group_id = ?))
|
|
)""")
|
|
params.extend([person_group_id, person_group_id, person_group_id, person_group_id])
|
|
# TAG filter: included tags (pass through if none defined)
|
|
where_clauses.append("""(
|
|
NOT EXISTS (SELECT 1 FROM private_media_person_group_tag_members WHERE group_id = ?)
|
|
OR m.id IN (SELECT mt.media_id FROM private_media_tags mt WHERE mt.tag_id IN (SELECT tag_id FROM private_media_person_group_tag_members WHERE group_id = ?))
|
|
)""")
|
|
params.extend([person_group_id, person_group_id])
|
|
# EXCLUDE filter: excluded tags
|
|
where_clauses.append("""
|
|
m.id NOT IN (SELECT mt2.media_id FROM private_media_tags mt2 WHERE mt2.tag_id IN (SELECT tag_id FROM private_media_person_group_excluded_tags WHERE group_id = ?))
|
|
""")
|
|
params.append(person_group_id)
|
|
|
|
if file_type and file_type != 'all':
|
|
where_clauses.append("m.file_type = ?")
|
|
params.append(file_type)
|
|
|
|
if tag_ids:
|
|
tag_id_list = [int(t) for t in tag_ids.split(',') if t.strip().isdigit()]
|
|
if tag_id_list:
|
|
placeholders = ','.join('?' * len(tag_id_list))
|
|
where_clauses.append(f'''
|
|
(m.id IN (SELECT media_id FROM private_media_tags WHERE tag_id IN ({placeholders}))
|
|
OR m.post_id IN (SELECT post_id FROM private_media_post_tags WHERE tag_id IN ({placeholders})))
|
|
''')
|
|
params.extend(tag_id_list)
|
|
params.extend(tag_id_list)
|
|
|
|
if relationship_id is not None:
|
|
where_clauses.append("p.relationship_id = ?")
|
|
params.append(relationship_id)
|
|
|
|
# Filter by has_tags (SQL-based for efficiency)
|
|
if has_tags is True:
|
|
where_clauses.append("EXISTS (SELECT 1 FROM private_media_tags WHERE media_id = m.id)")
|
|
elif has_tags is False:
|
|
where_clauses.append("NOT EXISTS (SELECT 1 FROM private_media_tags WHERE media_id = m.id)")
|
|
|
|
if unread_only:
|
|
where_clauses.append("post.is_read = 0")
|
|
|
|
# Merge explicit min_resolution with person group's min_resolution (use higher)
|
|
effective_min_res = min_resolution or 0
|
|
if person_group_id is not None:
|
|
cursor.execute('SELECT min_resolution FROM private_media_person_groups WHERE id = ?', (person_group_id,))
|
|
group_row = cursor.fetchone()
|
|
if group_row and group_row['min_resolution']:
|
|
effective_min_res = max(effective_min_res, group_row['min_resolution'])
|
|
|
|
if effective_min_res > 0:
|
|
where_clauses.append("(m.file_type != 'image' OR (m.width >= ? AND m.height >= ?))")
|
|
params.extend([effective_min_res, effective_min_res])
|
|
|
|
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
|
|
|
# Get total count (before pagination)
|
|
count_sql = f'''
|
|
SELECT COUNT(*) as total
|
|
FROM private_media m
|
|
LEFT JOIN private_media_posts post ON m.post_id = post.id
|
|
LEFT JOIN private_media_persons p ON m.person_id = p.id
|
|
WHERE {where_sql}
|
|
'''
|
|
cursor.execute(count_sql, params)
|
|
total = cursor.fetchone()['total']
|
|
|
|
# Shuffle mode: deterministic shuffle using PostgreSQL md5 hash
|
|
if shuffle:
|
|
seed = str(shuffle_seed if shuffle_seed is not None else 42)
|
|
total_filtered = total
|
|
query = f'''
|
|
SELECT m.*, p.encrypted_name as person_encrypted_name,
|
|
p.relationship_id, r.encrypted_name as rel_encrypted_name, r.color as rel_color,
|
|
post.created_at as post_created_at, post.encrypted_media_date as post_encrypted_media_date
|
|
FROM private_media m
|
|
LEFT JOIN private_media_posts post ON m.post_id = post.id
|
|
LEFT JOIN private_media_persons p ON m.person_id = p.id
|
|
LEFT JOIN private_media_relationships r ON p.relationship_id = r.id
|
|
WHERE {where_sql}
|
|
ORDER BY md5(m.id::text || ?::text), m.id
|
|
LIMIT ? OFFSET ?
|
|
'''
|
|
cursor.execute(query, params + [seed, limit, offset])
|
|
rows = cursor.fetchall()
|
|
|
|
# Skip re-sorting below — shuffle order is authoritative
|
|
can_sql_paginate = True
|
|
|
|
# Optimization: when no encrypted-field filters are active (no search, date_from,
|
|
# date_to, has_description) and sort is by created_at, paginate in SQL directly
|
|
# to avoid decrypting every row in the database.
|
|
elif not shuffle:
|
|
needs_decrypt_filter = bool(search or date_from or date_to or has_description is not None)
|
|
needs_decrypt_sort = sort_by in ('media_date', 'filename')
|
|
can_sql_paginate = not needs_decrypt_filter and not needs_decrypt_sort
|
|
|
|
if not shuffle and can_sql_paginate:
|
|
# Fast path: SQL-level pagination (sort_by=created_at only)
|
|
count_sql = f'''
|
|
SELECT COUNT(*) as total
|
|
FROM private_media m
|
|
LEFT JOIN private_media_posts post ON m.post_id = post.id
|
|
LEFT JOIN private_media_persons p ON m.person_id = p.id
|
|
WHERE {where_sql}
|
|
'''
|
|
cursor.execute(count_sql, params)
|
|
total_filtered = cursor.fetchone()[0]
|
|
|
|
order_dir = sort_order.upper() if sort_order.upper() in ('ASC', 'DESC') else 'DESC'
|
|
# Sort by post.created_at to exactly match the posts endpoint sort order,
|
|
# then m.id ASC within each post to match attachment display order
|
|
order_clause = f'post.created_at {order_dir}, m.id ASC'
|
|
query = f'''
|
|
SELECT m.*, p.encrypted_name as person_encrypted_name,
|
|
p.relationship_id, r.encrypted_name as rel_encrypted_name, r.color as rel_color,
|
|
post.created_at as post_created_at, post.encrypted_media_date as post_encrypted_media_date
|
|
FROM private_media m
|
|
LEFT JOIN private_media_posts post ON m.post_id = post.id
|
|
LEFT JOIN private_media_persons p ON m.person_id = p.id
|
|
LEFT JOIN private_media_relationships r ON p.relationship_id = r.id
|
|
WHERE {where_sql}
|
|
ORDER BY {order_clause}
|
|
LIMIT ? OFFSET ?
|
|
'''
|
|
cursor.execute(query, params + [limit, offset])
|
|
rows = cursor.fetchall()
|
|
elif not shuffle:
|
|
# Slow path: fetch all, decrypt, filter, sort, then paginate in Python
|
|
# Also fetch post-level fields for sorting by post.media_date (matching posts endpoint)
|
|
query = f'''
|
|
SELECT m.*, p.encrypted_name as person_encrypted_name,
|
|
p.relationship_id, r.encrypted_name as rel_encrypted_name, r.color as rel_color,
|
|
post.created_at as post_created_at, post.encrypted_media_date as post_encrypted_media_date
|
|
FROM private_media m
|
|
LEFT JOIN private_media_posts post ON m.post_id = post.id
|
|
LEFT JOIN private_media_persons p ON m.person_id = p.id
|
|
LEFT JOIN private_media_relationships r ON p.relationship_id = r.id
|
|
WHERE {where_sql}
|
|
ORDER BY post.created_at DESC, m.id ASC
|
|
'''
|
|
cursor.execute(query, params)
|
|
rows = cursor.fetchall()
|
|
|
|
# Decrypt and process items
|
|
items = []
|
|
config = _get_config(db)
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
|
|
for row in rows:
|
|
item = dict(row)
|
|
|
|
# Decrypt fields
|
|
item['filename'] = crypto.decrypt_field(item['encrypted_filename'])
|
|
item['description'] = crypto.decrypt_field(item['encrypted_description']) if item['encrypted_description'] else None
|
|
item['media_date'] = crypto.decrypt_field(item['encrypted_media_date'])
|
|
item['source_path'] = crypto.decrypt_field(item['encrypted_source_path']) if item['encrypted_source_path'] else None
|
|
|
|
# Decrypt post-level media_date for sorting (to match posts endpoint sort order)
|
|
item['post_media_date'] = crypto.decrypt_field(item['post_encrypted_media_date']) if item.get('post_encrypted_media_date') else None
|
|
|
|
# Remove encrypted fields
|
|
del item['encrypted_filename']
|
|
del item['encrypted_description']
|
|
del item['encrypted_media_date']
|
|
del item['encrypted_source_path']
|
|
if 'post_encrypted_media_date' in item:
|
|
del item['post_encrypted_media_date']
|
|
|
|
# Add person info
|
|
if item['person_encrypted_name']:
|
|
item['person'] = {
|
|
'id': item['person_id'],
|
|
'name': crypto.decrypt_field(item['person_encrypted_name']),
|
|
'relationship': {
|
|
'id': item['relationship_id'],
|
|
'name': crypto.decrypt_field(item['rel_encrypted_name']) if item['rel_encrypted_name'] else None,
|
|
'color': item['rel_color']
|
|
}
|
|
}
|
|
else:
|
|
item['person'] = None
|
|
|
|
del item['person_encrypted_name']
|
|
del item['rel_encrypted_name']
|
|
del item['rel_color']
|
|
|
|
# Add URLs
|
|
item['thumbnail_url'] = f"/api/private-gallery/thumbnail/{item['id']}"
|
|
item['stream_url'] = f"/api/private-gallery/stream/{item['id']}"
|
|
item['file_url'] = f"/api/private-gallery/file/{item['id']}"
|
|
|
|
items.append(item)
|
|
|
|
# Re-sort items within each post by id ASC to match the posts endpoint and paginated attachments endpoint
|
|
# Skip re-sorting in shuffle mode — shuffle order is authoritative
|
|
if items and not shuffle:
|
|
post_order = []
|
|
seen = set()
|
|
by_post = {}
|
|
for item in items:
|
|
pid = item.get('post_id')
|
|
if pid not in seen:
|
|
post_order.append(pid)
|
|
seen.add(pid)
|
|
by_post[pid] = []
|
|
by_post[pid].append(item)
|
|
for pid in by_post:
|
|
by_post[pid].sort(key=lambda x: x.get('id', 0))
|
|
items = []
|
|
for pid in post_order:
|
|
items.extend(by_post[pid])
|
|
|
|
if not can_sql_paginate:
|
|
# Apply date filtering (on decrypted dates)
|
|
if date_from:
|
|
items = [i for i in items if i['media_date'] >= date_from]
|
|
if date_to:
|
|
items = [i for i in items if i['media_date'] <= date_to]
|
|
|
|
# Apply search (on decrypted fields) - each word must match in filename or description
|
|
if search:
|
|
words = search.lower().split()
|
|
if words:
|
|
items = [i for i in items if all(
|
|
w in (i.get('filename') or '').lower() or w in (i.get('description') or '').lower()
|
|
for w in words
|
|
)]
|
|
|
|
# Filter by has_description (after decryption since description is encrypted)
|
|
if has_description is True:
|
|
items = [i for i in items if i.get('description') and i['description'].strip()]
|
|
elif has_description is False:
|
|
items = [i for i in items if not i.get('description') or not i['description'].strip()]
|
|
|
|
# Sort by decrypted field — use post-level media_date to match posts endpoint sort
|
|
if sort_by == 'media_date':
|
|
items.sort(key=lambda x: (x.get('post_media_date') or x.get('media_date') or '', x.get('post_created_at') or ''), reverse=(sort_order == 'desc'))
|
|
elif sort_by == 'filename':
|
|
items.sort(key=lambda x: (x.get('filename') or '').lower(), reverse=(sort_order == 'desc'))
|
|
elif sort_by == 'created_at' and sort_order == 'asc':
|
|
items.reverse() # SQL default is DESC, reverse for ASC
|
|
|
|
# Apply pagination
|
|
total_filtered = len(items)
|
|
items = items[offset:offset + limit]
|
|
|
|
# Get tags for each item
|
|
media_ids = [i['id'] for i in items]
|
|
if media_ids:
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
placeholders = ','.join('?' * len(media_ids))
|
|
cursor.execute(f'''
|
|
SELECT mt.media_id, t.id, t.encrypted_name, t.color
|
|
FROM private_media_tags mt
|
|
JOIN private_gallery_tags t ON mt.tag_id = t.id
|
|
WHERE mt.media_id IN ({placeholders})
|
|
''', media_ids)
|
|
|
|
tags_by_media = {}
|
|
for row in cursor.fetchall():
|
|
media_id = row['media_id']
|
|
if media_id not in tags_by_media:
|
|
tags_by_media[media_id] = []
|
|
tags_by_media[media_id].append({
|
|
'id': row['id'],
|
|
'name': crypto.decrypt_field(row['encrypted_name']),
|
|
'color': row['color']
|
|
})
|
|
|
|
for item in items:
|
|
item['tags'] = tags_by_media.get(item['id'], [])
|
|
|
|
# Clean up internal sort fields before response
|
|
for item in items:
|
|
item.pop('post_media_date', None)
|
|
item.pop('post_created_at', None)
|
|
item.pop('post_encrypted_media_date', None)
|
|
|
|
return {
|
|
"items": items,
|
|
"total": total_filtered,
|
|
"offset": offset,
|
|
"limit": limit,
|
|
"has_more": offset + limit < total_filtered
|
|
}
|
|
|
|
|
|
@router.get("/posts")
|
|
@limiter.limit("120/minute")
|
|
@handle_exceptions
|
|
async def get_posts(
|
|
request: Request,
|
|
offset: int = Query(0, ge=0),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
person_id: Optional[int] = None,
|
|
person_group_id: Optional[int] = None,
|
|
relationship_id: Optional[int] = None,
|
|
tag_ids: Optional[str] = None,
|
|
file_type: Optional[str] = None,
|
|
search: Optional[str] = None,
|
|
sort_by: str = Query("media_date", regex="^(media_date|created_at)$"),
|
|
sort_order: str = Query("desc", regex="^(asc|desc)$"),
|
|
date_from: Optional[str] = None,
|
|
date_to: Optional[str] = None,
|
|
has_tags: Optional[bool] = None,
|
|
has_description: Optional[bool] = None,
|
|
unread_only: Optional[bool] = None,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get posts with their grouped media items."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
# Ensure private_gallery_tags table exists and is migrated
|
|
_ensure_private_gallery_tags_migrated(db, crypto)
|
|
|
|
# Build cache key from SQL-level filter params
|
|
cache_key = f"{person_id}|{person_group_id}|{relationship_id}|{tag_ids}|{has_tags}|{file_type}|{unread_only}|{_posts_cache_version}"
|
|
|
|
# Check cache for decrypted base post list (with TTL)
|
|
cached = _posts_cache.get(cache_key)
|
|
if cached is not None:
|
|
cache_age = time.time() - _posts_cache_time.get(cache_key, 0)
|
|
if cache_age > _POSTS_CACHE_TTL:
|
|
cached = None # expired
|
|
if cached is not None:
|
|
all_posts = [dict(p) for p in cached] # shallow copy each dict
|
|
else:
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Build WHERE clauses for posts
|
|
where_clauses = []
|
|
params = []
|
|
|
|
if person_id is not None:
|
|
where_clauses.append("post.person_id = ?")
|
|
params.append(person_id)
|
|
|
|
if person_group_id is not None:
|
|
# WHO filter: persons + relationships (pass through if none defined)
|
|
where_clauses.append("""(
|
|
(
|
|
NOT EXISTS (SELECT 1 FROM private_media_person_group_members WHERE group_id = ?)
|
|
AND NOT EXISTS (SELECT 1 FROM private_media_person_group_relationship_members WHERE group_id = ?)
|
|
)
|
|
OR post.person_id IN (SELECT person_id FROM private_media_person_group_members WHERE group_id = ?)
|
|
OR post.person_id IN (SELECT p2.id FROM private_media_persons p2 WHERE p2.relationship_id IN (SELECT relationship_id FROM private_media_person_group_relationship_members WHERE group_id = ?))
|
|
)""")
|
|
params.extend([person_group_id, person_group_id, person_group_id, person_group_id])
|
|
# TAG filter: included tags (pass through if none defined)
|
|
where_clauses.append("""(
|
|
NOT EXISTS (SELECT 1 FROM private_media_person_group_tag_members WHERE group_id = ?)
|
|
OR post.id IN (SELECT DISTINCT m_tg.post_id FROM private_media m_tg JOIN private_media_tags mt_tg ON mt_tg.media_id = m_tg.id WHERE mt_tg.tag_id IN (SELECT tag_id FROM private_media_person_group_tag_members WHERE group_id = ?) AND m_tg.post_id IS NOT NULL)
|
|
)""")
|
|
params.extend([person_group_id, person_group_id])
|
|
# EXCLUDE filter: excluded tags
|
|
where_clauses.append("""
|
|
post.id NOT IN (SELECT DISTINCT m_ex.post_id FROM private_media m_ex JOIN private_media_tags mt_ex ON mt_ex.media_id = m_ex.id WHERE mt_ex.tag_id IN (SELECT tag_id FROM private_media_person_group_excluded_tags WHERE group_id = ?) AND m_ex.post_id IS NOT NULL)
|
|
""")
|
|
params.append(person_group_id)
|
|
|
|
if relationship_id is not None:
|
|
where_clauses.append("p.relationship_id = ?")
|
|
params.append(relationship_id)
|
|
|
|
if tag_ids:
|
|
tag_id_list = [int(t) for t in tag_ids.split(',') if t.strip().isdigit()]
|
|
if tag_id_list:
|
|
placeholders = ','.join('?' * len(tag_id_list))
|
|
where_clauses.append(f'''
|
|
post.id IN (
|
|
SELECT post_id FROM private_media_post_tags WHERE tag_id IN ({placeholders})
|
|
UNION
|
|
SELECT DISTINCT m.post_id FROM private_media m
|
|
JOIN private_media_tags mt ON mt.media_id = m.id
|
|
WHERE mt.tag_id IN ({placeholders}) AND m.post_id IS NOT NULL
|
|
)
|
|
''')
|
|
params.extend(tag_id_list)
|
|
params.extend(tag_id_list)
|
|
|
|
if has_tags is True:
|
|
where_clauses.append("EXISTS (SELECT 1 FROM private_media_post_tags WHERE post_id = post.id)")
|
|
elif has_tags is False:
|
|
where_clauses.append("NOT EXISTS (SELECT 1 FROM private_media_post_tags WHERE post_id = post.id)")
|
|
|
|
# Filter by file_type (posts that have at least one media item of that type)
|
|
if file_type and file_type != 'all':
|
|
where_clauses.append(f'''
|
|
EXISTS (SELECT 1 FROM private_media m WHERE m.post_id = post.id AND m.file_type = ?)
|
|
''')
|
|
params.append(file_type)
|
|
|
|
if unread_only:
|
|
where_clauses.append("post.is_read = 0")
|
|
|
|
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
|
|
|
# Get posts with person info
|
|
query = f'''
|
|
SELECT post.*, p.encrypted_name as person_encrypted_name,
|
|
p.relationship_id, r.encrypted_name as rel_encrypted_name, r.color as rel_color
|
|
FROM private_media_posts post
|
|
LEFT JOIN private_media_persons p ON post.person_id = p.id
|
|
LEFT JOIN private_media_relationships r ON p.relationship_id = r.id
|
|
WHERE {where_sql}
|
|
ORDER BY post.created_at DESC
|
|
'''
|
|
cursor.execute(query, params)
|
|
rows = cursor.fetchall()
|
|
|
|
# Decrypt and process posts
|
|
all_posts = []
|
|
|
|
for row in rows:
|
|
post = dict(row)
|
|
|
|
# Decrypt post fields
|
|
post['description'] = crypto.decrypt_field(post['encrypted_description']) if post['encrypted_description'] else None
|
|
post['media_date'] = crypto.decrypt_field(post['encrypted_media_date'])
|
|
|
|
# Remove encrypted fields
|
|
del post['encrypted_description']
|
|
del post['encrypted_media_date']
|
|
|
|
# Add person info
|
|
if post.get('person_encrypted_name'):
|
|
post['person'] = {
|
|
'id': post['person_id'],
|
|
'name': crypto.decrypt_field(post['person_encrypted_name']),
|
|
'relationship': {
|
|
'id': post['relationship_id'],
|
|
'name': crypto.decrypt_field(post['rel_encrypted_name']) if post.get('rel_encrypted_name') else None,
|
|
'color': post.get('rel_color')
|
|
}
|
|
}
|
|
else:
|
|
post['person'] = None
|
|
|
|
# Clean up temp fields
|
|
for key in ['person_encrypted_name', 'rel_encrypted_name', 'rel_color']:
|
|
if key in post:
|
|
del post[key]
|
|
|
|
all_posts.append(post)
|
|
|
|
# Store in cache with timestamp
|
|
with _posts_cache_lock:
|
|
_posts_cache[cache_key] = all_posts
|
|
_posts_cache_time[cache_key] = time.time()
|
|
all_posts = [dict(p) for p in all_posts] # work on copies
|
|
|
|
posts = all_posts
|
|
|
|
# Apply date filtering (on decrypted dates)
|
|
if date_from:
|
|
posts = [p for p in posts if p['media_date'] >= date_from]
|
|
if date_to:
|
|
posts = [p for p in posts if p['media_date'] <= date_to]
|
|
|
|
# Apply search (on decrypted fields) - each word must match in description
|
|
if search:
|
|
words = search.lower().split()
|
|
if words:
|
|
posts = [p for p in posts if all(
|
|
w in (p.get('description') or '').lower()
|
|
for w in words
|
|
)]
|
|
|
|
# Filter by has_description (after decryption)
|
|
if has_description is True:
|
|
posts = [p for p in posts if p.get('description') and p['description'].strip()]
|
|
elif has_description is False:
|
|
posts = [p for p in posts if not p.get('description') or not p['description'].strip()]
|
|
|
|
# Sort by decrypted field, with created_at as tiebreaker
|
|
if sort_by == 'media_date':
|
|
posts.sort(key=lambda x: (x.get('media_date') or '', x.get('created_at') or ''), reverse=(sort_order == 'desc'))
|
|
elif sort_order == 'asc':
|
|
posts.reverse() # Default order is DESC from SQL, reverse for ASC
|
|
|
|
# Apply pagination
|
|
total_filtered = len(posts)
|
|
|
|
# Count total media items across ALL filtered posts (before pagination)
|
|
all_filtered_post_ids = [p['id'] for p in posts]
|
|
total_media = 0
|
|
if all_filtered_post_ids:
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
placeholders = ','.join('?' * len(all_filtered_post_ids))
|
|
if file_type and file_type != 'all':
|
|
cursor.execute(f'''
|
|
SELECT COUNT(*) as cnt FROM private_media
|
|
WHERE post_id IN ({placeholders}) AND file_type = ?
|
|
''', all_filtered_post_ids + [file_type])
|
|
else:
|
|
cursor.execute(f'''
|
|
SELECT COUNT(*) as cnt FROM private_media
|
|
WHERE post_id IN ({placeholders})
|
|
''', all_filtered_post_ids)
|
|
total_media = cursor.fetchone()['cnt']
|
|
|
|
posts = posts[offset:offset + limit]
|
|
|
|
# Get media items for each post (capped at 100 per post for performance)
|
|
ATTACHMENT_CAP = 100
|
|
post_ids = [p['id'] for p in posts]
|
|
media_by_post = {}
|
|
media_count_by_post = {}
|
|
media_type_counts_by_post = {}
|
|
if post_ids:
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
placeholders = ','.join('?' * len(post_ids))
|
|
file_type_clause = ''
|
|
file_type_params = []
|
|
if file_type and file_type != 'all':
|
|
file_type_clause = ' AND m.file_type = ?'
|
|
file_type_params = [file_type]
|
|
|
|
# Get total counts per post with image/video breakdown (cheap — no decryption)
|
|
cursor.execute(f'''
|
|
SELECT m.post_id, COUNT(*) as cnt,
|
|
SUM(CASE WHEN m.file_type = 'image' THEN 1 ELSE 0 END) as image_count,
|
|
SUM(CASE WHEN m.file_type = 'video' THEN 1 ELSE 0 END) as video_count
|
|
FROM private_media m
|
|
WHERE m.post_id IN ({placeholders}){file_type_clause}
|
|
GROUP BY m.post_id
|
|
''', list(post_ids) + file_type_params)
|
|
for row in cursor.fetchall():
|
|
media_count_by_post[row['post_id']] = row['cnt']
|
|
media_type_counts_by_post[row['post_id']] = {
|
|
'image_count': row['image_count'],
|
|
'video_count': row['video_count']
|
|
}
|
|
|
|
# Fetch capped media per post using window function
|
|
media_query_params = list(post_ids) + file_type_params
|
|
cursor.execute(f'''
|
|
SELECT * FROM (
|
|
SELECT m.*, p.encrypted_name as person_encrypted_name,
|
|
p.relationship_id, r.encrypted_name as rel_encrypted_name, r.color as rel_color,
|
|
ROW_NUMBER() OVER (PARTITION BY m.post_id ORDER BY m.id ASC) as rn
|
|
FROM private_media m
|
|
LEFT JOIN private_media_persons p ON m.person_id = p.id
|
|
LEFT JOIN private_media_relationships r ON p.relationship_id = r.id
|
|
WHERE m.post_id IN ({placeholders}){file_type_clause}
|
|
) WHERE rn <= {ATTACHMENT_CAP}
|
|
''', media_query_params)
|
|
|
|
for row in cursor.fetchall():
|
|
media = dict(row)
|
|
post_id = media['post_id']
|
|
del media['rn']
|
|
|
|
# Decrypt media fields
|
|
media['filename'] = crypto.decrypt_field(media['encrypted_filename'])
|
|
media['description'] = crypto.decrypt_field(media['encrypted_description']) if media['encrypted_description'] else None
|
|
media['media_date'] = crypto.decrypt_field(media['encrypted_media_date'])
|
|
media['source_path'] = crypto.decrypt_field(media['encrypted_source_path']) if media['encrypted_source_path'] else None
|
|
|
|
# Remove encrypted fields
|
|
for key in ['encrypted_filename', 'encrypted_description', 'encrypted_media_date', 'encrypted_source_path']:
|
|
if key in media:
|
|
del media[key]
|
|
|
|
# Add URLs
|
|
media['thumbnail_url'] = f"/api/private-gallery/thumbnail/{media['id']}"
|
|
media['stream_url'] = f"/api/private-gallery/stream/{media['id']}"
|
|
media['file_url'] = f"/api/private-gallery/file/{media['id']}"
|
|
|
|
# Clean up temp fields
|
|
for key in ['person_encrypted_name', 'rel_encrypted_name', 'rel_color']:
|
|
if key in media:
|
|
del media[key]
|
|
|
|
if post_id not in media_by_post:
|
|
media_by_post[post_id] = []
|
|
media_by_post[post_id].append(media)
|
|
|
|
# Sort attachments within each post by id ascending (matches ROW_NUMBER window and paginated endpoint)
|
|
for pid in media_by_post:
|
|
media_by_post[pid].sort(key=lambda x: x.get('id', 0))
|
|
|
|
# Get tags for each post
|
|
tags_by_post = {}
|
|
if post_ids:
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
placeholders = ','.join('?' * len(post_ids))
|
|
cursor.execute(f'''
|
|
SELECT pt.post_id, t.id, t.encrypted_name, t.color
|
|
FROM private_media_post_tags pt
|
|
JOIN private_gallery_tags t ON pt.tag_id = t.id
|
|
WHERE pt.post_id IN ({placeholders})
|
|
''', post_ids)
|
|
|
|
for row in cursor.fetchall():
|
|
post_id = row['post_id']
|
|
if post_id not in tags_by_post:
|
|
tags_by_post[post_id] = []
|
|
tags_by_post[post_id].append({
|
|
'id': row['id'],
|
|
'name': crypto.decrypt_field(row['encrypted_name']),
|
|
'color': row['color']
|
|
})
|
|
|
|
# Attach media and tags to posts
|
|
for post in posts:
|
|
post['attachments'] = media_by_post.get(post['id'], [])
|
|
post['tags'] = tags_by_post.get(post['id'], [])
|
|
post['attachment_count'] = media_count_by_post.get(post['id'], 0)
|
|
type_counts = media_type_counts_by_post.get(post['id'], {})
|
|
post['image_count'] = type_counts.get('image_count', 0)
|
|
post['video_count'] = type_counts.get('video_count', 0)
|
|
|
|
return {
|
|
"posts": posts,
|
|
"total": total_filtered,
|
|
"total_media": total_media,
|
|
"offset": offset,
|
|
"limit": limit,
|
|
"has_more": offset + limit < total_filtered
|
|
}
|
|
|
|
|
|
@router.get("/new-posts-count")
|
|
@limiter.limit("120/minute")
|
|
@handle_exceptions
|
|
async def get_new_posts_count(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get count of unread posts."""
|
|
db = _get_db()
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT COUNT(*) as cnt FROM private_media_posts WHERE is_read = 0')
|
|
count = cursor.fetchone()['cnt']
|
|
|
|
return {"count": count}
|
|
|
|
|
|
@router.post("/mark-posts-seen")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def mark_posts_seen(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Mark all unread posts as read."""
|
|
db = _get_db()
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('UPDATE private_media_posts SET is_read = 1 WHERE is_read = 0')
|
|
updated = cursor.rowcount
|
|
conn.commit()
|
|
|
|
_invalidate_posts_cache()
|
|
return {"updated": updated}
|
|
|
|
|
|
@router.put("/posts/batch/read-status")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def batch_update_read_status(
|
|
request: Request,
|
|
body: BatchReadStatusRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Update read status for multiple posts."""
|
|
if not body.post_ids:
|
|
return {"updated": 0}
|
|
|
|
db = _get_db()
|
|
is_read_val = 1 if body.is_read else 0
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
placeholders = ','.join('?' * len(body.post_ids))
|
|
cursor.execute(
|
|
f'UPDATE private_media_posts SET is_read = ? WHERE id IN ({placeholders})',
|
|
[is_read_val] + body.post_ids
|
|
)
|
|
updated = cursor.rowcount
|
|
conn.commit()
|
|
|
|
_invalidate_posts_cache()
|
|
return {"updated": updated}
|
|
|
|
|
|
@router.put("/posts/{post_id}/read-status")
|
|
@limiter.limit("120/minute")
|
|
@handle_exceptions
|
|
async def update_post_read_status(
|
|
request: Request,
|
|
post_id: int,
|
|
body: dict,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Update read status for a single post."""
|
|
db = _get_db()
|
|
is_read_val = 1 if body.get('is_read') else 0
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('UPDATE private_media_posts SET is_read = ? WHERE id = ?', (is_read_val, post_id))
|
|
if cursor.rowcount == 0:
|
|
raise NotFoundError(f"Post {post_id} not found")
|
|
conn.commit()
|
|
|
|
_invalidate_posts_cache()
|
|
return {"post_id": post_id, "is_read": is_read_val}
|
|
|
|
|
|
@router.get("/posts/{post_id}/attachments")
|
|
@limiter.limit("120/minute")
|
|
@handle_exceptions
|
|
async def get_post_attachments(
|
|
request: Request,
|
|
post_id: int,
|
|
offset: int = Query(0, ge=0),
|
|
limit: int = Query(100, ge=1, le=500),
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get paginated attachments for a post."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Verify post exists
|
|
cursor.execute('SELECT id FROM private_media_posts WHERE id = ?', (post_id,))
|
|
if not cursor.fetchone():
|
|
raise NotFoundError(f"Post {post_id} not found")
|
|
|
|
# Fetch all media for this post, decrypt, sort by media_date DESC (matches post view)
|
|
cursor.execute('''
|
|
SELECT m.*, p.encrypted_name as person_encrypted_name,
|
|
p.relationship_id, r.encrypted_name as rel_encrypted_name, r.color as rel_color
|
|
FROM private_media m
|
|
LEFT JOIN private_media_persons p ON m.person_id = p.id
|
|
LEFT JOIN private_media_relationships r ON p.relationship_id = r.id
|
|
WHERE m.post_id = ?
|
|
''', (post_id,))
|
|
|
|
all_items = []
|
|
for row in cursor.fetchall():
|
|
media = dict(row)
|
|
media['filename'] = crypto.decrypt_field(media['encrypted_filename'])
|
|
media['description'] = crypto.decrypt_field(media['encrypted_description']) if media['encrypted_description'] else None
|
|
media['media_date'] = crypto.decrypt_field(media['encrypted_media_date'])
|
|
media['source_path'] = crypto.decrypt_field(media['encrypted_source_path']) if media['encrypted_source_path'] else None
|
|
|
|
for key in ['encrypted_filename', 'encrypted_description', 'encrypted_media_date', 'encrypted_source_path']:
|
|
if key in media:
|
|
del media[key]
|
|
|
|
media['thumbnail_url'] = f"/api/private-gallery/thumbnail/{media['id']}"
|
|
media['stream_url'] = f"/api/private-gallery/stream/{media['id']}"
|
|
media['file_url'] = f"/api/private-gallery/file/{media['id']}"
|
|
|
|
for key in ['person_encrypted_name', 'rel_encrypted_name', 'rel_color']:
|
|
if key in media:
|
|
del media[key]
|
|
|
|
all_items.append(media)
|
|
|
|
# Sort by id ascending (matches posts endpoint and ROW_NUMBER window function)
|
|
all_items.sort(key=lambda x: x.get('id', 0))
|
|
total = len(all_items)
|
|
items = all_items[offset:offset + limit]
|
|
|
|
return {
|
|
"items": items,
|
|
"total": total,
|
|
"offset": offset,
|
|
"limit": limit,
|
|
"has_more": offset + limit < total
|
|
}
|
|
|
|
|
|
@router.put("/posts/{post_id}")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def update_post(
|
|
request: Request,
|
|
post_id: int,
|
|
body: PostUpdateRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Update a post's metadata."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
# Verify post exists
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id FROM private_media_posts WHERE id = ?', (post_id,))
|
|
if not cursor.fetchone():
|
|
raise NotFoundError(f"Post {post_id} not found")
|
|
|
|
updates = []
|
|
params = []
|
|
|
|
if body.description is not None:
|
|
updates.append("encrypted_description = ?")
|
|
params.append(crypto.encrypt_field(body.description) if body.description else None)
|
|
|
|
if body.person_id is not None:
|
|
updates.append("person_id = ?")
|
|
params.append(body.person_id if body.person_id > 0 else None)
|
|
|
|
if body.media_date is not None:
|
|
updates.append("encrypted_media_date = ?")
|
|
params.append(crypto.encrypt_field(body.media_date))
|
|
|
|
if updates:
|
|
updates.append("updated_at = CURRENT_TIMESTAMP")
|
|
params.append(post_id)
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(f'''
|
|
UPDATE private_media_posts
|
|
SET {", ".join(updates)}
|
|
WHERE id = ?
|
|
''', params)
|
|
conn.commit()
|
|
|
|
# Update tags if provided
|
|
if body.tag_ids is not None:
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
# Remove existing tags
|
|
cursor.execute('DELETE FROM private_media_post_tags WHERE post_id = ?', (post_id,))
|
|
# Add new tags
|
|
for tag_id in body.tag_ids:
|
|
cursor.execute('''
|
|
INSERT INTO private_media_post_tags (post_id, tag_id)
|
|
VALUES (?, ?)
|
|
''', (post_id, tag_id))
|
|
conn.commit()
|
|
|
|
_invalidate_posts_cache()
|
|
return message_response("Post updated")
|
|
|
|
|
|
@router.delete("/posts/{post_id}")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def delete_post(
|
|
request: Request,
|
|
post_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Delete a post and all its media items."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
# Get all media items for this post
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT storage_id FROM private_media WHERE post_id = ?', (post_id,))
|
|
media_rows = cursor.fetchall()
|
|
|
|
if not media_rows:
|
|
# Check if post exists with no media
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id FROM private_media_posts WHERE id = ?', (post_id,))
|
|
if not cursor.fetchone():
|
|
raise NotFoundError(f"Post {post_id} not found")
|
|
|
|
# Get storage path
|
|
config = _get_config(db)
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
thumbs_path = storage_path / 'thumbs'
|
|
|
|
# Delete files (ignore errors — files may be missing or on a failed disk)
|
|
# Skip exists() checks — they hang for ~6s on I/O error disks, causing request timeouts
|
|
for row in media_rows:
|
|
storage_id = row['storage_id']
|
|
for path in [
|
|
storage_path / 'data' / f"{storage_id}.enc",
|
|
thumbs_path / f"{storage_id}.enc",
|
|
]:
|
|
try:
|
|
path.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
except OSError as e:
|
|
logger.warning(f"Could not delete {path}: {e}")
|
|
|
|
# Delete from database (cascades to media items and tags)
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
# Delete media items (cascade from posts should handle this, but be explicit)
|
|
cursor.execute('DELETE FROM private_media WHERE post_id = ?', (post_id,))
|
|
# Delete post tags
|
|
cursor.execute('DELETE FROM private_media_post_tags WHERE post_id = ?', (post_id,))
|
|
# Delete post
|
|
cursor.execute('DELETE FROM private_media_posts WHERE id = ?', (post_id,))
|
|
conn.commit()
|
|
|
|
_invalidate_posts_cache()
|
|
return message_response("Post deleted")
|
|
|
|
|
|
@router.post("/posts")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def create_post(
|
|
request: Request,
|
|
body: PostCreateRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Create a new post, optionally attaching existing media."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
final_date = body.media_date or date.today().isoformat()
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Verify person exists
|
|
cursor.execute('SELECT id FROM private_media_persons WHERE id = ?', (body.person_id,))
|
|
if not cursor.fetchone():
|
|
raise ValidationError(f"Person {body.person_id} not found")
|
|
|
|
# Create the post
|
|
cursor.execute('''
|
|
INSERT INTO private_media_posts (
|
|
person_id, encrypted_description, encrypted_media_date
|
|
) VALUES (?, ?, ?)
|
|
''', (
|
|
body.person_id,
|
|
crypto.encrypt_field(body.description) if body.description else None,
|
|
crypto.encrypt_field(final_date)
|
|
))
|
|
post_id = cursor.lastrowid
|
|
|
|
# Add tags to the post
|
|
if body.tag_ids:
|
|
for tag_id in body.tag_ids:
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO private_media_post_tags (post_id, tag_id)
|
|
VALUES (?, ?)
|
|
''', (post_id, tag_id))
|
|
|
|
# Attach existing media if provided
|
|
attached = 0
|
|
if body.media_ids:
|
|
for media_id in body.media_ids:
|
|
cursor.execute('SELECT id, post_id FROM private_media WHERE id = ?', (media_id,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
continue
|
|
|
|
source_post_id = row['post_id']
|
|
|
|
# Record original_post_id if not already set
|
|
if source_post_id is not None:
|
|
cursor.execute('SELECT original_post_id FROM private_media WHERE id = ?', (media_id,))
|
|
orig = cursor.fetchone()
|
|
if orig and orig['original_post_id'] is None:
|
|
cursor.execute('UPDATE private_media SET original_post_id = ? WHERE id = ?', (source_post_id, media_id))
|
|
|
|
# Move to new post
|
|
cursor.execute('UPDATE private_media SET post_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (post_id, media_id))
|
|
attached += 1
|
|
|
|
conn.commit()
|
|
|
|
_invalidate_posts_cache()
|
|
return {"post_id": post_id, "attached": attached}
|
|
|
|
|
|
def _scan_duplicates_background(job_id: str, post_id: int, person_id: int):
|
|
"""Background task to scan for perceptual duplicates across a person's media."""
|
|
import threading
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
config = _get_config(db)
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
data_path = storage_path / 'data'
|
|
|
|
try:
|
|
# Get all media for this person
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT id, post_id, storage_id, encrypted_filename, file_type, perceptual_hash, width, height
|
|
FROM private_media
|
|
WHERE post_id IN (SELECT id FROM private_media_posts WHERE person_id = ?)
|
|
AND file_type IN ('image', 'video')
|
|
''', (person_id,))
|
|
all_media = cursor.fetchall()
|
|
|
|
total = len(all_media)
|
|
_update_pg_job(job_id, {'total_files': total, 'current_phase': 'hashing'})
|
|
|
|
if total < 2:
|
|
_update_pg_job(job_id, {
|
|
'status': 'completed',
|
|
'scan_results': [],
|
|
'total_scanned': total,
|
|
'total_groups': 0,
|
|
'completed_at': datetime.now().isoformat(),
|
|
'message': f'Not enough media to compare ({total} found)'
|
|
})
|
|
return
|
|
|
|
# Backfill perceptual hashes
|
|
media_list = []
|
|
for idx, row in enumerate(all_media):
|
|
phash = row['perceptual_hash']
|
|
media_id = row['id']
|
|
if not phash:
|
|
encrypted_file = data_path / f"{row['storage_id']}.enc"
|
|
if encrypted_file.exists():
|
|
try:
|
|
temp_dir = Path(tempfile.gettempdir())
|
|
orig_name = crypto.decrypt_field(row['encrypted_filename']) or ''
|
|
ext = Path(orig_name).suffix or ('.jpg' if row['file_type'] == 'image' else '.mp4' if row['file_type'] == 'video' else '')
|
|
temp_file = temp_dir / f"pg_phash_{row['storage_id']}{ext}"
|
|
crypto.decrypt_file(encrypted_file, temp_file)
|
|
phash = _compute_perceptual_hash(temp_file)
|
|
temp_file.unlink(missing_ok=True)
|
|
if phash:
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('UPDATE private_media SET perceptual_hash = ? WHERE id = ?', (phash, media_id))
|
|
conn.commit()
|
|
except Exception:
|
|
pass
|
|
if phash:
|
|
media_list.append({
|
|
'id': media_id,
|
|
'post_id': row['post_id'],
|
|
'storage_id': row['storage_id'],
|
|
'filename': crypto.decrypt_field(row['encrypted_filename']),
|
|
'file_type': row['file_type'],
|
|
'perceptual_hash': phash,
|
|
'width': row['width'],
|
|
'height': row['height'],
|
|
})
|
|
_update_pg_job(job_id, {
|
|
'processed_files': idx + 1,
|
|
'success_count': len(media_list),
|
|
})
|
|
|
|
# Compare all pairs using hamming distance (optimized with pre-computed integers)
|
|
total_pairs = len(media_list) * (len(media_list) - 1) // 2
|
|
_update_pg_job(job_id, {'current_phase': 'comparing', 'total_pairs': total_pairs, 'compared_pairs': 0})
|
|
|
|
# Pre-compute integer values for fast XOR-based hamming distance
|
|
hash_ints = {}
|
|
for m in media_list:
|
|
try:
|
|
hash_ints[m['id']] = int(m['perceptual_hash'], 16)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
threshold = 12
|
|
parent = {m['id']: m['id'] for m in media_list}
|
|
|
|
def find(x):
|
|
while parent[x] != x:
|
|
parent[x] = parent[parent[x]]
|
|
x = parent[x]
|
|
return x
|
|
|
|
def union(x, y):
|
|
px, py = find(x), find(y)
|
|
if px != py:
|
|
parent[px] = py
|
|
|
|
pair_distances = {}
|
|
compared = 0
|
|
for i in range(len(media_list)):
|
|
id_i = media_list[i]['id']
|
|
if id_i not in hash_ints:
|
|
compared += len(media_list) - i - 1
|
|
continue
|
|
hi = hash_ints[id_i]
|
|
for j in range(i + 1, len(media_list)):
|
|
id_j = media_list[j]['id']
|
|
compared += 1
|
|
if id_j not in hash_ints:
|
|
continue
|
|
dist = bin(hi ^ hash_ints[id_j]).count('1')
|
|
if dist <= threshold:
|
|
union(id_i, id_j)
|
|
pair_distances[(id_i, id_j)] = dist
|
|
pair_distances[(id_j, id_i)] = dist
|
|
# Update progress periodically
|
|
if i % 50 == 0:
|
|
_update_pg_job(job_id, {'compared_pairs': compared})
|
|
|
|
# Group by root
|
|
groups: Dict[int, list] = {}
|
|
for m in media_list:
|
|
root = find(m['id'])
|
|
if root not in groups:
|
|
groups[root] = []
|
|
groups[root].append(m)
|
|
|
|
duplicate_groups = []
|
|
for group in groups.values():
|
|
if len(group) > 1:
|
|
items = []
|
|
for m in group:
|
|
distances_to_others = {}
|
|
for other in group:
|
|
if other['id'] != m['id']:
|
|
key = (m['id'], other['id'])
|
|
if key in pair_distances:
|
|
distances_to_others[str(other['id'])] = pair_distances[key]
|
|
items.append({
|
|
'media_id': m['id'],
|
|
'post_id': m['post_id'],
|
|
'storage_id': m['storage_id'],
|
|
'filename': m['filename'],
|
|
'file_type': m['file_type'],
|
|
'width': m['width'],
|
|
'height': m['height'],
|
|
'distances': distances_to_others,
|
|
})
|
|
duplicate_groups.append({'items': items})
|
|
|
|
results_data = {
|
|
'scan_results': duplicate_groups,
|
|
'total_scanned': len(media_list),
|
|
'total_groups': len(duplicate_groups),
|
|
}
|
|
_update_pg_job(job_id, {
|
|
'status': 'completed',
|
|
'completed_at': datetime.now().isoformat(),
|
|
**results_data,
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"[ScanDuplicates] Background scan failed: {e}", module="PrivateGallery")
|
|
_update_pg_job(job_id, {
|
|
'status': 'completed',
|
|
'scan_results': [],
|
|
'total_scanned': 0,
|
|
'total_groups': 0,
|
|
'completed_at': datetime.now().isoformat(),
|
|
'message': f'Scan failed: {str(e)}'
|
|
})
|
|
|
|
|
|
def _scan_all_duplicates_background(job_id: str):
|
|
"""Background task to scan for perceptual duplicates across ALL persons' media."""
|
|
import threading
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
config = _get_config(db)
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
data_path = storage_path / 'data'
|
|
|
|
try:
|
|
# Get all persons and build name lookup
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, encrypted_name FROM private_media_persons')
|
|
person_rows = cursor.fetchall()
|
|
|
|
person_names = {}
|
|
for p in person_rows:
|
|
try:
|
|
person_names[p['id']] = crypto.decrypt_field(p['encrypted_name']) or f"Person {p['id']}"
|
|
except Exception:
|
|
person_names[p['id']] = f"Person {p['id']}"
|
|
|
|
# Fetch ALL media with a person assigned
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT m.id, m.post_id, m.storage_id, m.encrypted_filename, m.file_type,
|
|
m.perceptual_hash, m.width, m.height, post.person_id
|
|
FROM private_media m
|
|
JOIN private_media_posts post ON m.post_id = post.id
|
|
WHERE post.person_id IS NOT NULL
|
|
AND m.file_type IN ('image', 'video')
|
|
''')
|
|
all_media = cursor.fetchall()
|
|
|
|
total = len(all_media)
|
|
_update_pg_job(job_id, {'total_files': total, 'current_phase': 'hashing'})
|
|
|
|
if total < 2:
|
|
_update_pg_job(job_id, {
|
|
'status': 'completed',
|
|
'scan_results': [],
|
|
'total_scanned': total,
|
|
'total_groups': 0,
|
|
'completed_at': datetime.now().isoformat(),
|
|
'message': f'Not enough media to compare ({total} found)'
|
|
})
|
|
return
|
|
|
|
# Phase 1: Backfill perceptual hashes
|
|
media_list = []
|
|
for idx, row in enumerate(all_media):
|
|
phash = row['perceptual_hash']
|
|
media_id = row['id']
|
|
if not phash:
|
|
encrypted_file = data_path / f"{row['storage_id']}.enc"
|
|
if encrypted_file.exists():
|
|
try:
|
|
temp_dir = Path(tempfile.gettempdir())
|
|
orig_name = crypto.decrypt_field(row['encrypted_filename']) or ''
|
|
ext = Path(orig_name).suffix or ('.jpg' if row['file_type'] == 'image' else '.mp4' if row['file_type'] == 'video' else '')
|
|
temp_file = temp_dir / f"pg_phash_{row['storage_id']}{ext}"
|
|
crypto.decrypt_file(encrypted_file, temp_file)
|
|
phash = _compute_perceptual_hash(temp_file)
|
|
temp_file.unlink(missing_ok=True)
|
|
if phash:
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('UPDATE private_media SET perceptual_hash = ? WHERE id = ?', (phash, media_id))
|
|
conn.commit()
|
|
except Exception:
|
|
pass
|
|
if phash:
|
|
media_list.append({
|
|
'id': media_id,
|
|
'post_id': row['post_id'],
|
|
'person_id': row['person_id'],
|
|
'storage_id': row['storage_id'],
|
|
'filename': crypto.decrypt_field(row['encrypted_filename']),
|
|
'file_type': row['file_type'],
|
|
'perceptual_hash': phash,
|
|
'width': row['width'],
|
|
'height': row['height'],
|
|
})
|
|
_update_pg_job(job_id, {
|
|
'processed_files': idx + 1,
|
|
'success_count': len(media_list),
|
|
})
|
|
|
|
# Phase 2: Compare within each person group
|
|
# Group media by person_id
|
|
person_groups: Dict[int, list] = {}
|
|
for m in media_list:
|
|
pid = m['person_id']
|
|
if pid not in person_groups:
|
|
person_groups[pid] = []
|
|
person_groups[pid].append(m)
|
|
|
|
# Count total pairs across all person groups
|
|
total_pairs = 0
|
|
for group in person_groups.values():
|
|
n = len(group)
|
|
total_pairs += n * (n - 1) // 2
|
|
|
|
_update_pg_job(job_id, {'current_phase': 'comparing', 'total_pairs': total_pairs, 'compared_pairs': 0})
|
|
|
|
threshold = 12
|
|
parent = {m['id']: m['id'] for m in media_list}
|
|
|
|
def find(x):
|
|
while parent[x] != x:
|
|
parent[x] = parent[parent[x]]
|
|
x = parent[x]
|
|
return x
|
|
|
|
def union(x, y):
|
|
px, py = find(x), find(y)
|
|
if px != py:
|
|
parent[px] = py
|
|
|
|
pair_distances = {}
|
|
compared = 0
|
|
|
|
# Pre-compute integer values for fast XOR-based hamming distance
|
|
hash_ints = {}
|
|
for m in media_list:
|
|
try:
|
|
hash_ints[m['id']] = int(m['perceptual_hash'], 16)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
for pid, group in person_groups.items():
|
|
pname = person_names.get(pid, f"Person {pid}")
|
|
_update_pg_job(job_id, {'current_person': pname})
|
|
|
|
for i in range(len(group)):
|
|
id_i = group[i]['id']
|
|
if id_i not in hash_ints:
|
|
compared += len(group) - i - 1
|
|
continue
|
|
hi = hash_ints[id_i]
|
|
for j in range(i + 1, len(group)):
|
|
id_j = group[j]['id']
|
|
compared += 1
|
|
if id_j not in hash_ints:
|
|
continue
|
|
dist = bin(hi ^ hash_ints[id_j]).count('1')
|
|
if dist <= threshold:
|
|
union(id_i, id_j)
|
|
pair_distances[(id_i, id_j)] = dist
|
|
pair_distances[(id_j, id_i)] = dist
|
|
if i % 50 == 0:
|
|
_update_pg_job(job_id, {'compared_pairs': compared})
|
|
|
|
# Group by root
|
|
groups: Dict[int, list] = {}
|
|
for m in media_list:
|
|
root = find(m['id'])
|
|
if root not in groups:
|
|
groups[root] = []
|
|
groups[root].append(m)
|
|
|
|
duplicate_groups = []
|
|
for group in groups.values():
|
|
if len(group) > 1:
|
|
# All items in a group share the same person_id (we only compare within person)
|
|
pid = group[0]['person_id']
|
|
pname = person_names.get(pid, f"Person {pid}")
|
|
items = []
|
|
for m in group:
|
|
distances_to_others = {}
|
|
for other in group:
|
|
if other['id'] != m['id']:
|
|
key = (m['id'], other['id'])
|
|
if key in pair_distances:
|
|
distances_to_others[str(other['id'])] = pair_distances[key]
|
|
items.append({
|
|
'media_id': m['id'],
|
|
'post_id': m['post_id'],
|
|
'person_id': m['person_id'],
|
|
'person_name': pname,
|
|
'storage_id': m['storage_id'],
|
|
'filename': m['filename'],
|
|
'file_type': m['file_type'],
|
|
'width': m['width'],
|
|
'height': m['height'],
|
|
'distances': distances_to_others,
|
|
})
|
|
duplicate_groups.append({'items': items, 'person_name': pname})
|
|
|
|
results_data = {
|
|
'scan_results': duplicate_groups,
|
|
'total_scanned': len(media_list),
|
|
'total_groups': len(duplicate_groups),
|
|
}
|
|
_update_pg_job(job_id, {
|
|
'status': 'completed',
|
|
'completed_at': datetime.now().isoformat(),
|
|
**results_data,
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"[ScanAllDuplicates] Background scan failed: {e}", module="PrivateGallery")
|
|
_update_pg_job(job_id, {
|
|
'status': 'completed',
|
|
'scan_results': [],
|
|
'total_scanned': 0,
|
|
'total_groups': 0,
|
|
'completed_at': datetime.now().isoformat(),
|
|
'message': f'Scan failed: {str(e)}'
|
|
})
|
|
|
|
|
|
@router.post("/posts/{post_id}/scan-duplicates")
|
|
@limiter.limit("5/minute")
|
|
@handle_exceptions
|
|
async def scan_duplicates_for_person(
|
|
request: Request,
|
|
post_id: int,
|
|
background_tasks: BackgroundTasks,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Start a background scan for perceptual duplicates across this post's person's media."""
|
|
db = _get_db()
|
|
|
|
# Get person_id from the post
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT person_id FROM private_media_posts WHERE id = ?', (post_id,))
|
|
post_row = cursor.fetchone()
|
|
|
|
if not post_row:
|
|
raise NotFoundError(f"Post {post_id} not found")
|
|
|
|
person_id = post_row['person_id']
|
|
if not person_id:
|
|
return {"job_id": None, "message": "Post has no person assigned"}
|
|
|
|
job_id = f"scan-{post_id}-{uuid.uuid4().hex[:8]}"
|
|
_create_pg_job(job_id, 0, 'scan-duplicates')
|
|
_update_pg_job(job_id, {'person_id': person_id, 'current_phase': 'starting'})
|
|
|
|
import threading
|
|
thread = threading.Thread(target=_scan_duplicates_background, args=(job_id, post_id, person_id), daemon=True)
|
|
thread.start()
|
|
|
|
return {"job_id": job_id}
|
|
|
|
|
|
@router.post("/scan-all-duplicates")
|
|
@limiter.limit("2/minute")
|
|
@handle_exceptions
|
|
async def scan_all_duplicates(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Start a background scan for perceptual duplicates across ALL persons' media."""
|
|
db = _get_db()
|
|
|
|
job_id = f"scan-all-{uuid.uuid4().hex[:8]}"
|
|
_create_pg_job(job_id, 0, 'scan-all-duplicates')
|
|
_update_pg_job(job_id, {'current_phase': 'starting'})
|
|
|
|
import threading
|
|
thread = threading.Thread(target=_scan_all_duplicates_background, args=(job_id,), daemon=True)
|
|
thread.start()
|
|
|
|
return {"job_id": job_id}
|
|
|
|
|
|
@router.post("/posts/cleanup-empty")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def cleanup_empty_posts(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Delete posts that have no media and no description."""
|
|
body = await request.json()
|
|
post_ids = body.get('post_ids', [])
|
|
if not post_ids or not isinstance(post_ids, list):
|
|
return {"deleted_post_ids": []}
|
|
|
|
db = _get_db()
|
|
deleted = []
|
|
|
|
for pid in post_ids:
|
|
try:
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
# Check media count
|
|
cursor.execute('SELECT COUNT(*) as cnt FROM private_media WHERE post_id = ?', (pid,))
|
|
media_count = cursor.fetchone()['cnt']
|
|
if media_count > 0:
|
|
continue
|
|
# Check if post is tagged 'reddit' (skip description check for reddit posts)
|
|
cursor.execute('SELECT id FROM private_media_posts WHERE id = ?', (pid,))
|
|
post_row = cursor.fetchone()
|
|
if not post_row:
|
|
continue
|
|
is_reddit = False
|
|
cursor.execute('''
|
|
SELECT t.encrypted_name FROM private_media_post_tags pt
|
|
JOIN private_gallery_tags t ON t.id = pt.tag_id
|
|
WHERE pt.post_id = ?
|
|
''', (pid,))
|
|
crypto = _get_crypto()
|
|
for tag_row in cursor.fetchall():
|
|
try:
|
|
tag_name = crypto.decrypt_field(tag_row['encrypted_name'])
|
|
if tag_name and tag_name.lower() == 'reddit':
|
|
is_reddit = True
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
if not is_reddit:
|
|
# For non-reddit posts, only delete if no description
|
|
cursor.execute('SELECT encrypted_description FROM private_media_posts WHERE id = ?', (pid,))
|
|
row = cursor.fetchone()
|
|
if row and row['encrypted_description']:
|
|
desc = crypto.decrypt_field(row['encrypted_description'])
|
|
if desc and desc.strip():
|
|
continue
|
|
|
|
# Post has no media — delete it
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('DELETE FROM private_media_post_tags WHERE post_id = ?', (pid,))
|
|
cursor.execute('DELETE FROM private_media_posts WHERE id = ?', (pid,))
|
|
conn.commit()
|
|
deleted.append(pid)
|
|
except Exception as e:
|
|
logger.error(f"[CleanupEmpty] Failed to check/delete post {pid}: {e}", module="PrivateGallery")
|
|
|
|
if deleted:
|
|
_invalidate_posts_cache()
|
|
return {"deleted_post_ids": deleted}
|
|
|
|
|
|
@router.post("/posts/{post_id}/attach-media")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def attach_media_to_post(
|
|
request: Request,
|
|
post_id: int,
|
|
body: AttachMediaRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Attach existing media items to a post by copying them (originals remain on their source post)."""
|
|
db = _get_db()
|
|
|
|
config = _get_config(db)
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
data_path = storage_path / 'data'
|
|
thumbs_path = storage_path / 'thumbs'
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Verify target post exists
|
|
cursor.execute('SELECT id FROM private_media_posts WHERE id = ?', (post_id,))
|
|
if not cursor.fetchone():
|
|
raise NotFoundError(f"Post {post_id} not found")
|
|
|
|
attached = 0
|
|
for media_id in body.media_ids:
|
|
# Get full media record to copy
|
|
cursor.execute('''
|
|
SELECT storage_id, encrypted_filename, encrypted_description,
|
|
file_hash, file_size, file_type, mime_type,
|
|
width, height, duration, person_id,
|
|
encrypted_media_date, source_type, post_id
|
|
FROM private_media WHERE id = ?
|
|
''', (media_id,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
continue
|
|
|
|
# Skip if already on target post
|
|
if row['post_id'] == post_id:
|
|
continue
|
|
|
|
# Create a copy with a new storage_id
|
|
new_storage_id = str(uuid.uuid4())
|
|
|
|
# Hard-link the encrypted data file (shares disk space, independent deletion)
|
|
src_data = data_path / f"{row['storage_id']}.enc"
|
|
dst_data = data_path / f"{new_storage_id}.enc"
|
|
if src_data.exists():
|
|
try:
|
|
os.link(str(src_data), str(dst_data))
|
|
except OSError:
|
|
# Fallback to copy if hard link fails (e.g. cross-device)
|
|
import shutil
|
|
shutil.copy2(str(src_data), str(dst_data))
|
|
|
|
# Hard-link the encrypted thumbnail
|
|
src_thumb = thumbs_path / f"{row['storage_id']}.enc"
|
|
dst_thumb = thumbs_path / f"{new_storage_id}.enc"
|
|
if src_thumb.exists():
|
|
try:
|
|
os.link(str(src_thumb), str(dst_thumb))
|
|
except OSError:
|
|
import shutil
|
|
shutil.copy2(str(src_thumb), str(dst_thumb))
|
|
|
|
# Insert new media record pointing to the target post
|
|
cursor.execute('''
|
|
INSERT INTO private_media (
|
|
post_id, storage_id, encrypted_filename, encrypted_description,
|
|
file_hash, file_size, file_type, mime_type,
|
|
width, height, duration, person_id,
|
|
encrypted_media_date, source_type, original_post_id
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
''', (
|
|
post_id, new_storage_id, row['encrypted_filename'], row['encrypted_description'],
|
|
row['file_hash'], row['file_size'], row['file_type'], row['mime_type'],
|
|
row['width'], row['height'], row['duration'], row['person_id'],
|
|
row['encrypted_media_date'], row['source_type'], row['post_id']
|
|
))
|
|
attached += 1
|
|
|
|
conn.commit()
|
|
|
|
return {"attached": attached}
|
|
|
|
|
|
@router.get("/media/{media_id}")
|
|
@limiter.limit("120/minute")
|
|
@handle_exceptions
|
|
async def get_media_item(
|
|
request: Request,
|
|
media_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get a single media item."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
# Ensure private_gallery_tags table exists and is migrated
|
|
_ensure_private_gallery_tags_migrated(db, crypto)
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT m.*, p.encrypted_name as person_encrypted_name,
|
|
p.relationship_id, r.encrypted_name as rel_encrypted_name, r.color as rel_color
|
|
FROM private_media m
|
|
LEFT JOIN private_media_persons p ON m.person_id = p.id
|
|
LEFT JOIN private_media_relationships r ON p.relationship_id = r.id
|
|
WHERE m.id = ?
|
|
''', (media_id,))
|
|
row = cursor.fetchone()
|
|
|
|
if not row:
|
|
raise NotFoundError(f"Media {media_id} not found")
|
|
|
|
item = dict(row)
|
|
|
|
# Decrypt fields
|
|
item['filename'] = crypto.decrypt_field(item['encrypted_filename'])
|
|
item['description'] = crypto.decrypt_field(item['encrypted_description']) if item['encrypted_description'] else None
|
|
item['media_date'] = crypto.decrypt_field(item['encrypted_media_date'])
|
|
item['source_path'] = crypto.decrypt_field(item['encrypted_source_path']) if item['encrypted_source_path'] else None
|
|
|
|
del item['encrypted_filename']
|
|
del item['encrypted_description']
|
|
del item['encrypted_media_date']
|
|
del item['encrypted_source_path']
|
|
|
|
# Add person info
|
|
if item['person_encrypted_name']:
|
|
item['person'] = {
|
|
'id': item['person_id'],
|
|
'name': crypto.decrypt_field(item['person_encrypted_name']),
|
|
'relationship': {
|
|
'id': item['relationship_id'],
|
|
'name': crypto.decrypt_field(item['rel_encrypted_name']) if item['rel_encrypted_name'] else None,
|
|
'color': item['rel_color']
|
|
}
|
|
}
|
|
else:
|
|
item['person'] = None
|
|
|
|
del item['person_encrypted_name']
|
|
del item['rel_encrypted_name']
|
|
del item['rel_color']
|
|
|
|
# Get tags
|
|
cursor.execute('''
|
|
SELECT t.id, t.encrypted_name, t.color
|
|
FROM private_media_tags mt
|
|
JOIN private_gallery_tags t ON mt.tag_id = t.id
|
|
WHERE mt.media_id = ?
|
|
''', (media_id,))
|
|
item['tags'] = [{
|
|
'id': r['id'],
|
|
'name': crypto.decrypt_field(r['encrypted_name']),
|
|
'color': r['color']
|
|
} for r in cursor.fetchall()]
|
|
|
|
# Add URLs
|
|
item['thumbnail_url'] = f"/api/private-gallery/thumbnail/{item['id']}"
|
|
item['stream_url'] = f"/api/private-gallery/stream/{item['id']}"
|
|
item['file_url'] = f"/api/private-gallery/file/{item['id']}"
|
|
|
|
return item
|
|
|
|
|
|
@router.put("/media/{media_id}")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def update_media(
|
|
request: Request,
|
|
media_id: int,
|
|
body: MediaUpdateRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Update media metadata."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
updates = []
|
|
params = []
|
|
|
|
if body.description is not None:
|
|
updates.append("encrypted_description = ?")
|
|
params.append(crypto.encrypt_field(body.description) if body.description else None)
|
|
|
|
if body.person_id is not None:
|
|
updates.append("person_id = ?")
|
|
params.append(body.person_id if body.person_id > 0 else None)
|
|
|
|
if body.media_date is not None:
|
|
updates.append("encrypted_media_date = ?")
|
|
params.append(crypto.encrypt_field(body.media_date))
|
|
|
|
if updates:
|
|
updates.append("updated_at = CURRENT_TIMESTAMP")
|
|
params.append(media_id)
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(f'''
|
|
UPDATE private_media
|
|
SET {", ".join(updates)}
|
|
WHERE id = ?
|
|
''', params)
|
|
if cursor.rowcount == 0:
|
|
raise NotFoundError(f"Media {media_id} not found")
|
|
conn.commit()
|
|
|
|
# Update tags if provided
|
|
if body.tag_ids is not None:
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
# Remove existing tags
|
|
cursor.execute('DELETE FROM private_media_tags WHERE media_id = ?', (media_id,))
|
|
# Add new tags
|
|
for tag_id in body.tag_ids:
|
|
cursor.execute('''
|
|
INSERT INTO private_media_tags (media_id, tag_id)
|
|
VALUES (?, ?)
|
|
''', (media_id, tag_id))
|
|
conn.commit()
|
|
|
|
return message_response("Media updated")
|
|
|
|
|
|
def _cleanup_empty_reddit_posts(db, crypto, storage_path: Path):
|
|
"""Delete posts that have no media attachments and are tagged 'reddit'."""
|
|
try:
|
|
# Find the reddit tag ID by decrypting tag names
|
|
reddit_tag_id = None
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT id, encrypted_name FROM private_gallery_tags")
|
|
for row in cursor.fetchall():
|
|
try:
|
|
name = crypto.decrypt_field(row['encrypted_name'])
|
|
if name and name.lower() == 'reddit':
|
|
reddit_tag_id = row['id']
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
if reddit_tag_id is None:
|
|
return 0
|
|
|
|
# Find posts tagged 'reddit' that have zero media
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT p.id FROM private_media_posts p
|
|
JOIN private_media_post_tags pt ON pt.post_id = p.id
|
|
WHERE pt.tag_id = ?
|
|
AND NOT EXISTS (SELECT 1 FROM private_media m WHERE m.post_id = p.id)
|
|
''', (reddit_tag_id,))
|
|
empty_posts = [row['id'] for row in cursor.fetchall()]
|
|
|
|
if not empty_posts:
|
|
return 0
|
|
|
|
# Delete each empty post
|
|
thumbs_path = storage_path / 'thumbs'
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
for post_id in empty_posts:
|
|
cursor.execute('DELETE FROM private_media_post_tags WHERE post_id = ?', (post_id,))
|
|
cursor.execute('DELETE FROM private_media_posts WHERE id = ?', (post_id,))
|
|
conn.commit()
|
|
|
|
if empty_posts:
|
|
_invalidate_posts_cache()
|
|
logger.info(f"Cleaned up {len(empty_posts)} empty reddit-tagged posts", module="PrivateGallery")
|
|
|
|
return len(empty_posts)
|
|
except Exception as e:
|
|
logger.error(f"Failed to cleanup empty reddit posts: {e}", module="PrivateGallery")
|
|
return 0
|
|
|
|
|
|
@router.delete("/media/{media_id}")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def delete_media(
|
|
request: Request,
|
|
media_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Delete a media item and its encrypted files."""
|
|
db = _get_db()
|
|
config = _get_config(db)
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Get storage_id
|
|
cursor.execute('SELECT storage_id FROM private_media WHERE id = ?', (media_id,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
raise NotFoundError(f"Media {media_id} not found")
|
|
|
|
storage_id = row['storage_id']
|
|
|
|
# Delete files
|
|
data_file = storage_path / 'data' / f"{storage_id}.enc"
|
|
thumb_file = storage_path / 'thumbs' / f"{storage_id}.enc"
|
|
|
|
if data_file.exists():
|
|
data_file.unlink()
|
|
if thumb_file.exists():
|
|
thumb_file.unlink()
|
|
|
|
# Delete database record (tags cascade)
|
|
cursor.execute('DELETE FROM private_media WHERE id = ?', (media_id,))
|
|
conn.commit()
|
|
|
|
_thumb_cache_invalidate(storage_id)
|
|
_invalidate_posts_cache()
|
|
|
|
# Clean up empty reddit-tagged posts
|
|
crypto = _get_crypto()
|
|
_cleanup_empty_reddit_posts(db, crypto, storage_path)
|
|
|
|
return message_response("Media deleted")
|
|
|
|
|
|
@router.post("/media/batch-delete")
|
|
@limiter.limit("120/minute")
|
|
@handle_exceptions
|
|
async def batch_delete_media(
|
|
request: Request,
|
|
body: BatchDeleteRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Delete multiple media items."""
|
|
db = _get_db()
|
|
config = _get_config(db)
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
|
|
deleted = 0
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
for media_id in body.media_ids:
|
|
cursor.execute('SELECT storage_id FROM private_media WHERE id = ?', (media_id,))
|
|
row = cursor.fetchone()
|
|
if row:
|
|
storage_id = row['storage_id']
|
|
|
|
# Delete files (skip exists() — hangs on I/O error disks)
|
|
for path in [
|
|
storage_path / 'data' / f"{storage_id}.enc",
|
|
storage_path / 'thumbs' / f"{storage_id}.enc",
|
|
]:
|
|
try:
|
|
path.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
except OSError as e:
|
|
logger.warning(f"Could not delete {path}: {e}")
|
|
|
|
cursor.execute('DELETE FROM private_media WHERE id = ?', (media_id,))
|
|
_thumb_cache_invalidate(storage_id)
|
|
deleted += 1
|
|
|
|
conn.commit()
|
|
|
|
_invalidate_posts_cache()
|
|
|
|
# Clean up empty reddit-tagged posts
|
|
crypto = _get_crypto()
|
|
cleaned = _cleanup_empty_reddit_posts(db, crypto, storage_path)
|
|
|
|
return {"deleted": deleted, "empty_reddit_posts_cleaned": cleaned}
|
|
|
|
|
|
# ============================================================================
|
|
# UPLOAD/COPY ENDPOINTS
|
|
# ============================================================================
|
|
|
|
def _upload_to_gallery_background(job_id, file_infos, post_id, person_id, tag_id_list, final_date):
|
|
"""Background task to process uploaded files into the private gallery."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
config = _get_config(db)
|
|
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
data_path = storage_path / 'data'
|
|
thumbs_path = storage_path / 'thumbs'
|
|
data_path.mkdir(parents=True, exist_ok=True)
|
|
thumbs_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
results = []
|
|
uploaded_media_ids = []
|
|
success_count = 0
|
|
failed_count = 0
|
|
duplicate_count = 0
|
|
skipped_count = 0
|
|
|
|
for idx, fi in enumerate(file_infos):
|
|
temp_file = Path(fi['temp_path'])
|
|
original_filename = fi['original_filename']
|
|
|
|
_update_pg_job(job_id, {
|
|
'current_file': original_filename,
|
|
'processed_files': idx
|
|
})
|
|
|
|
try:
|
|
# Calculate hash for duplicate detection
|
|
file_hash = _get_file_hash(temp_file)
|
|
|
|
# Check for duplicates (scoped by person)
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
if person_id:
|
|
cursor.execute('SELECT id, person_id FROM private_media WHERE file_hash = ? AND person_id = ?', (file_hash, person_id))
|
|
else:
|
|
cursor.execute('SELECT id, person_id FROM private_media WHERE file_hash = ?', (file_hash,))
|
|
existing = cursor.fetchone()
|
|
|
|
if existing:
|
|
temp_file.unlink(missing_ok=True)
|
|
duplicate_count += 1
|
|
results.append({
|
|
'filename': original_filename,
|
|
'status': 'duplicate',
|
|
'existing_id': existing['id']
|
|
})
|
|
_update_pg_job(job_id, {
|
|
'results': list(results),
|
|
'duplicate_count': duplicate_count,
|
|
'processed_files': idx + 1
|
|
})
|
|
continue
|
|
|
|
# Get file info
|
|
file_info = _get_file_info(temp_file)
|
|
file_size = temp_file.stat().st_size
|
|
|
|
# Skip low-resolution images
|
|
min_res = int(config.get('min_import_resolution', 0) or 0)
|
|
if min_res > 0 and file_info['file_type'] == 'image':
|
|
w = file_info.get('width') or 0
|
|
h = file_info.get('height') or 0
|
|
if w < min_res or h < min_res:
|
|
temp_file.unlink(missing_ok=True)
|
|
skipped_count += 1
|
|
results.append({
|
|
'filename': original_filename,
|
|
'status': 'skipped',
|
|
'reason': f'Low resolution ({w}x{h}, min {min_res}px)'
|
|
})
|
|
_update_pg_job(job_id, {
|
|
'results': list(results),
|
|
'skipped_count': skipped_count,
|
|
'processed_files': idx + 1
|
|
})
|
|
continue
|
|
|
|
# Compute perceptual hash before encryption
|
|
perceptual_hash = _compute_perceptual_hash(temp_file)
|
|
|
|
# Try to extract date from EXIF or filename
|
|
item_date = _extract_date_from_exif(temp_file)
|
|
if not item_date:
|
|
item_date = _extract_date_from_filename(original_filename)
|
|
if not item_date:
|
|
try:
|
|
from datetime import datetime as dt
|
|
mtime = temp_file.stat().st_mtime
|
|
item_date = dt.fromtimestamp(mtime).strftime('%Y-%m-%dT%H:%M:%S')
|
|
except Exception:
|
|
pass
|
|
if not item_date:
|
|
item_date = final_date
|
|
|
|
# Generate storage ID
|
|
storage_id = str(uuid.uuid4())
|
|
|
|
# Generate thumbnail first (before encrypting original)
|
|
temp_dir = Path(tempfile.gettempdir())
|
|
temp_thumb = temp_dir / f"pg_thumb_{storage_id}.jpg"
|
|
_generate_thumbnail(temp_file, temp_thumb, file_info['file_type'])
|
|
|
|
# Encrypt the original file
|
|
encrypted_file = data_path / f"{storage_id}.enc"
|
|
if not crypto.encrypt_file(temp_file, encrypted_file):
|
|
raise Exception("Encryption failed")
|
|
|
|
# Encrypt thumbnail if it exists
|
|
if temp_thumb.exists():
|
|
encrypted_thumb = thumbs_path / f"{storage_id}.enc"
|
|
crypto.encrypt_file(temp_thumb, encrypted_thumb)
|
|
temp_thumb.unlink()
|
|
|
|
# Clean up temp file
|
|
temp_file.unlink(missing_ok=True)
|
|
|
|
# Insert into database with post_id reference
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
INSERT INTO private_media (
|
|
post_id, storage_id, encrypted_filename, encrypted_description,
|
|
file_hash, file_size, file_type, mime_type,
|
|
width, height, duration, person_id,
|
|
encrypted_media_date, source_type, perceptual_hash
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
''', (
|
|
post_id,
|
|
storage_id,
|
|
crypto.encrypt_field(original_filename),
|
|
None,
|
|
file_hash,
|
|
file_size,
|
|
file_info['file_type'],
|
|
file_info['mime_type'],
|
|
file_info['width'],
|
|
file_info['height'],
|
|
file_info['duration'],
|
|
person_id,
|
|
crypto.encrypt_field(item_date),
|
|
'upload',
|
|
perceptual_hash
|
|
))
|
|
media_id = cursor.lastrowid
|
|
uploaded_media_ids.append(media_id)
|
|
|
|
for tag_id in tag_id_list:
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO private_media_tags (media_id, tag_id)
|
|
VALUES (?, ?)
|
|
''', (media_id, tag_id))
|
|
|
|
conn.commit()
|
|
|
|
success_count += 1
|
|
results.append({
|
|
'id': media_id,
|
|
'filename': original_filename,
|
|
'status': 'created',
|
|
'media_date': item_date
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Upload failed for {original_filename}: {e}")
|
|
failed_count += 1
|
|
temp_file.unlink(missing_ok=True)
|
|
results.append({
|
|
'filename': original_filename,
|
|
'status': 'failed',
|
|
'error': str(e)
|
|
})
|
|
|
|
_update_pg_job(job_id, {
|
|
'results': list(results),
|
|
'success_count': success_count,
|
|
'failed_count': failed_count,
|
|
'duplicate_count': duplicate_count,
|
|
'skipped_count': skipped_count,
|
|
'processed_files': idx + 1
|
|
})
|
|
|
|
# If no files were successfully uploaded, delete the empty post
|
|
if not uploaded_media_ids and post_id:
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('DELETE FROM private_media_post_tags WHERE post_id = ?', (post_id,))
|
|
cursor.execute('DELETE FROM private_media_posts WHERE id = ?', (post_id,))
|
|
conn.commit()
|
|
|
|
_invalidate_posts_cache()
|
|
_update_pg_job(job_id, {
|
|
'status': 'completed',
|
|
'completed_at': datetime.now().isoformat(),
|
|
'current_file': None,
|
|
'post_id': post_id if uploaded_media_ids else None
|
|
})
|
|
|
|
|
|
@router.post("/upload")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def upload_media(
|
|
request: Request,
|
|
background_tasks: BackgroundTasks,
|
|
files: List[UploadFile] = File(default=[]),
|
|
person_id: int = Form(...),
|
|
tag_ids: Optional[str] = Form(None),
|
|
media_date: Optional[str] = Form(None),
|
|
description: Optional[str] = Form(None),
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Upload media files with encryption. All files are grouped into a single post."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
# Parse tag IDs (optional)
|
|
tag_id_list = []
|
|
if tag_ids:
|
|
tag_id_list = [int(t) for t in tag_ids.split(',') if t.strip().isdigit()]
|
|
|
|
# Determine the media date for the post
|
|
final_date = media_date
|
|
if not final_date:
|
|
final_date = date.today().isoformat()
|
|
|
|
# Save all uploaded files to temp before responding (UploadFile objects become invalid after response)
|
|
file_infos = []
|
|
temp_dir = Path(tempfile.gettempdir())
|
|
for file in files:
|
|
temp_path = temp_dir / f"pg_upload_{uuid.uuid4()}{Path(file.filename).suffix}"
|
|
with open(temp_path, 'wb') as f:
|
|
shutil.copyfileobj(file.file, f)
|
|
file_infos.append({
|
|
'temp_path': str(temp_path),
|
|
'original_filename': file.filename,
|
|
'file_size': temp_path.stat().st_size
|
|
})
|
|
|
|
# Create the post to group all media items
|
|
post_id = None
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
INSERT INTO private_media_posts (
|
|
person_id, encrypted_description, encrypted_media_date
|
|
) VALUES (?, ?, ?)
|
|
''', (
|
|
person_id,
|
|
crypto.encrypt_field(description) if description else None,
|
|
crypto.encrypt_field(final_date)
|
|
))
|
|
post_id = cursor.lastrowid
|
|
|
|
for tag_id in tag_id_list:
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO private_media_post_tags (post_id, tag_id)
|
|
VALUES (?, ?)
|
|
''', (post_id, tag_id))
|
|
|
|
conn.commit()
|
|
|
|
# Create job and launch background task
|
|
job_id = f"pg_upload_{uuid.uuid4().hex[:12]}"
|
|
_create_pg_job(job_id, len(file_infos), 'upload')
|
|
|
|
background_tasks.add_task(
|
|
_upload_to_gallery_background,
|
|
job_id,
|
|
file_infos,
|
|
post_id,
|
|
person_id,
|
|
tag_id_list,
|
|
final_date
|
|
)
|
|
|
|
return {
|
|
"job_id": job_id,
|
|
"post_id": post_id,
|
|
"status": "processing",
|
|
"total_files": len(file_infos)
|
|
}
|
|
|
|
|
|
def _is_forum_thread_url(url):
|
|
"""Quick check if URL matches a Discourse thread pattern (no network request)."""
|
|
from urllib.parse import urlparse
|
|
path = urlparse(url).path
|
|
return bool(re.match(r'/t/(?:[^/]+/)?(\d+)(?:/\d+)?$', path))
|
|
|
|
|
|
def _resolve_forum_thread_urls(url):
|
|
"""If URL is a Discourse forum thread, scrape it and return list of image URLs. Otherwise return None."""
|
|
from urllib.parse import urlparse
|
|
|
|
parsed = urlparse(url)
|
|
path = parsed.path
|
|
|
|
# Detect Discourse thread URL pattern: /t/slug/topic_id or /t/topic_id
|
|
discourse_match = re.match(r'/t/(?:[^/]+/)?(\d+)(?:/\d+)?$', path)
|
|
if not discourse_match:
|
|
return None
|
|
|
|
topic_id = discourse_match.group(1)
|
|
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
|
|
|
try:
|
|
from curl_cffi import requests as cf_requests
|
|
from bs4 import BeautifulSoup
|
|
|
|
session = cf_requests.Session(impersonate='chrome')
|
|
|
|
# Fetch topic JSON
|
|
r = session.get(f"{base_url}/t/{topic_id}.json", timeout=30)
|
|
if r.status_code != 200:
|
|
logger.warning(f"Forum thread fetch failed: {r.status_code}")
|
|
return None
|
|
|
|
data = r.json()
|
|
stream = data.get('post_stream', {})
|
|
posts = stream.get('posts', [])
|
|
all_ids = stream.get('stream', [])
|
|
loaded_ids = {p['id'] for p in posts}
|
|
|
|
# Fetch remaining posts if needed (Discourse returns ~20 per page)
|
|
remaining = [pid for pid in all_ids if pid not in loaded_ids]
|
|
if remaining:
|
|
for i in range(0, len(remaining), 20):
|
|
chunk = remaining[i:i+20]
|
|
params = '&'.join(f'post_ids[]={pid}' for pid in chunk)
|
|
r2 = session.get(f"{base_url}/t/{topic_id}/posts.json?{params}", timeout=30)
|
|
if r2.status_code == 200:
|
|
extra = r2.json().get('post_stream', {}).get('posts', [])
|
|
posts.extend(extra)
|
|
|
|
# Extract full-size image URLs from lightbox links
|
|
image_urls = []
|
|
seen = set()
|
|
for post in posts:
|
|
cooked = post.get('cooked', '')
|
|
soup = BeautifulSoup(cooked, 'html.parser')
|
|
|
|
for a in soup.find_all('a', class_='lightbox'):
|
|
href = a.get('href', '')
|
|
if href and href not in seen:
|
|
seen.add(href)
|
|
image_urls.append(href)
|
|
|
|
title = data.get('title', '')
|
|
logger.info(f"Forum thread {topic_id}: found {len(image_urls)} media URLs from {len(posts)} posts")
|
|
return {'urls': image_urls, 'title': title} if image_urls else None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Forum thread scrape failed: {e}")
|
|
return None
|
|
|
|
|
|
def _is_erome_album_url(url):
|
|
"""Quick check if URL matches an erome.com album pattern."""
|
|
from urllib.parse import urlparse
|
|
parsed = urlparse(url)
|
|
return parsed.netloc in ('www.erome.com', 'erome.com') and bool(re.match(r'/a/[A-Za-z0-9]+$', parsed.path))
|
|
|
|
|
|
def _resolve_erome_album_urls(url):
|
|
"""If URL is an erome album, scrape it and return dict with media URLs and title. Otherwise return None."""
|
|
from urllib.parse import urlparse
|
|
|
|
parsed = urlparse(url)
|
|
if parsed.netloc not in ('www.erome.com', 'erome.com'):
|
|
return None
|
|
if not re.match(r'/a/[A-Za-z0-9]+$', parsed.path):
|
|
return None
|
|
|
|
try:
|
|
import requests as req_lib
|
|
from bs4 import BeautifulSoup
|
|
|
|
headers = {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
'Referer': 'https://www.erome.com/'
|
|
}
|
|
r = req_lib.get(url, headers=headers, timeout=30)
|
|
if r.status_code != 200:
|
|
logger.warning(f"Erome album fetch failed: {r.status_code}")
|
|
return None
|
|
|
|
soup = BeautifulSoup(r.text, 'html.parser')
|
|
|
|
title_el = soup.find('h1', class_='album-title-page')
|
|
title = title_el.text.strip() if title_el else ''
|
|
|
|
media_urls = []
|
|
seen = set()
|
|
for mg in soup.find_all('div', class_='media-group'):
|
|
img_div = mg.find('div', class_='img')
|
|
if not img_div:
|
|
continue
|
|
|
|
data_src = img_div.get('data-src')
|
|
data_html = img_div.get('data-html')
|
|
|
|
if data_src and not data_html:
|
|
# Image
|
|
if data_src not in seen:
|
|
seen.add(data_src)
|
|
media_urls.append(data_src)
|
|
elif data_html:
|
|
# Video - get first <source> src
|
|
source = mg.find('source')
|
|
if source and source.get('src') and source['src'] not in seen:
|
|
seen.add(source['src'])
|
|
media_urls.append(source['src'])
|
|
|
|
logger.info(f"Erome album {parsed.path}: found {len(media_urls)} media URLs")
|
|
return {'urls': media_urls, 'title': title} if media_urls else None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erome album scrape failed: {e}")
|
|
return None
|
|
|
|
|
|
_VENV_BIN = '/opt/media-downloader/venv/bin'
|
|
|
|
_TUBE_SITE_DOMAINS = {
|
|
'xhamster.com', 'xhamster.one', 'xhamster.desi', 'xhamster2.com', 'xhamster3.com',
|
|
'xvideos.com', 'xvideos2.com', 'xvideos.es',
|
|
'motherless.com',
|
|
}
|
|
|
|
|
|
def _is_tube_site_url(url):
|
|
"""Quick check if URL is from a supported tube/media site."""
|
|
from urllib.parse import urlparse
|
|
domain = urlparse(url).netloc.lower().replace('www.', '')
|
|
return domain in _TUBE_SITE_DOMAINS
|
|
|
|
|
|
def _resolve_tube_site_urls(url):
|
|
"""Resolve tube site URL into media URLs using gallery-dl/yt-dlp."""
|
|
import subprocess, json
|
|
|
|
try:
|
|
# gallery-dl -j returns JSON with [type, url_or_info, metadata]
|
|
result = subprocess.run(
|
|
[f'{_VENV_BIN}/gallery-dl', '-j', url],
|
|
capture_output=True, text=True, timeout=120
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
data = json.loads(result.stdout)
|
|
media_urls = []
|
|
title = ''
|
|
for entry in data:
|
|
if isinstance(entry, list) and len(entry) >= 3 and entry[0] == 3:
|
|
media_urls.append(entry[1]) # direct URL
|
|
if not title:
|
|
title = entry[2].get('title', '') or entry[2].get('album', '')
|
|
elif isinstance(entry, list) and len(entry) >= 2 and entry[0] == 2:
|
|
if not title and isinstance(entry[1], dict):
|
|
title = entry[1].get('title', '') or entry[1].get('album', '')
|
|
if media_urls:
|
|
is_video = any(
|
|
entry[2].get('extension', '') in ('mp4', 'webm', 'mkv')
|
|
or entry[2].get('type', '') == 'video'
|
|
for entry in data
|
|
if isinstance(entry, list) and len(entry) >= 3 and entry[0] == 3
|
|
)
|
|
return {'urls': media_urls, 'title': title, 'use_ytdlp': is_video, 'original_url': url}
|
|
except Exception as e:
|
|
logger.debug(f"gallery-dl resolve failed for {url}: {e}")
|
|
|
|
# Fallback: yt-dlp for video metadata
|
|
try:
|
|
result = subprocess.run(
|
|
[f'{_VENV_BIN}/yt-dlp', '--dump-json', '--no-download', '--no-warnings', url],
|
|
capture_output=True, text=True, timeout=60
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
info = json.loads(result.stdout.strip().split('\n')[0])
|
|
title = info.get('title', '')
|
|
return {'urls': [url], 'title': title, 'use_ytdlp': True, 'original_url': url}
|
|
except Exception as e:
|
|
logger.debug(f"yt-dlp resolve failed for {url}: {e}")
|
|
|
|
return None
|
|
|
|
|
|
def _import_urls_to_gallery_background(job_id, urls, post_id, person_id, tag_id_list, final_date, description):
|
|
"""Background task to download URLs and import them into the private gallery."""
|
|
import requests as req_lib
|
|
from modules.paid_content.file_host_downloader import FileHostDownloader
|
|
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
config = _get_config(db)
|
|
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
data_path = storage_path / 'data'
|
|
thumbs_path = storage_path / 'thumbs'
|
|
data_path.mkdir(parents=True, exist_ok=True)
|
|
thumbs_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
downloader = FileHostDownloader()
|
|
results = []
|
|
imported_media_ids = []
|
|
success_count = 0
|
|
failed_count = 0
|
|
duplicate_count = 0
|
|
skipped_count = 0
|
|
|
|
# Deduplicate URLs in the input list
|
|
seen_urls = set()
|
|
unique_urls = []
|
|
for u in urls:
|
|
if u not in seen_urls:
|
|
seen_urls.add(u)
|
|
unique_urls.append(u)
|
|
urls = unique_urls
|
|
|
|
# Resolve page URLs (forum threads, erome albums) into individual media URLs
|
|
resolved_urls = []
|
|
url_post_ids = {} # maps resolved URL -> post_id for album/thread-specific posts
|
|
url_referers = {} # maps resolved URL -> Referer header needed for download
|
|
has_resolvable = any(_is_forum_thread_url(u) or _is_erome_album_url(u) or _is_tube_site_url(u) for u in urls)
|
|
if has_resolvable:
|
|
_update_pg_job(job_id, {'current_phase': 'resolving'})
|
|
for u in urls:
|
|
# Try each resolver in order
|
|
page_result = _resolve_forum_thread_urls(u)
|
|
referer = None
|
|
if not page_result:
|
|
page_result = _resolve_erome_album_urls(u)
|
|
if page_result:
|
|
referer = 'https://www.erome.com/'
|
|
if not page_result and _is_tube_site_url(u):
|
|
page_result = _resolve_tube_site_urls(u)
|
|
if page_result and page_result.get('use_ytdlp'):
|
|
# Mark ytdlp URLs for special download handling
|
|
for media_url in page_result['urls']:
|
|
url_referers[media_url] = '__ytdlp__'
|
|
|
|
if page_result:
|
|
# Create a separate post for this album/thread
|
|
album_title = page_result['title']
|
|
album_post_id = None
|
|
try:
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
INSERT INTO private_media_posts (
|
|
person_id, encrypted_description, encrypted_media_date
|
|
) VALUES (?, ?, ?)
|
|
''', (
|
|
person_id,
|
|
crypto.encrypt_field(album_title) if album_title else None,
|
|
crypto.encrypt_field(final_date)
|
|
))
|
|
album_post_id = cursor.lastrowid
|
|
for tag_id in tag_id_list:
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO private_media_post_tags (post_id, tag_id)
|
|
VALUES (?, ?)
|
|
''', (album_post_id, tag_id))
|
|
conn.commit()
|
|
except Exception as e:
|
|
logger.error(f"Failed to create post for '{album_title}': {e}")
|
|
album_post_id = post_id # fall back to original post
|
|
|
|
for media_url in page_result['urls']:
|
|
url_post_ids[media_url] = album_post_id
|
|
if referer:
|
|
url_referers[media_url] = referer
|
|
resolved_urls.append(media_url)
|
|
else:
|
|
resolved_urls.append(u)
|
|
urls = resolved_urls
|
|
|
|
# If ALL urls came from threads, delete the original (now-unused) post
|
|
non_thread_urls = [u for u in urls if u not in url_post_ids]
|
|
if url_post_ids and not non_thread_urls and post_id:
|
|
try:
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('DELETE FROM private_media_post_tags WHERE post_id = ?', (post_id,))
|
|
cursor.execute('DELETE FROM private_media_posts WHERE id = ?', (post_id,))
|
|
conn.commit()
|
|
post_id = None
|
|
except Exception:
|
|
pass
|
|
|
|
# Update job total and resolved filenames so frontend can rebuild items
|
|
resolved_filenames = [u.rstrip('/').split('/')[-1].split('?')[0] or f'url-{i+1}' for i, u in enumerate(urls)]
|
|
_update_pg_job(job_id, {
|
|
'total_files': len(urls),
|
|
'resolved_filenames': resolved_filenames,
|
|
'current_phase': 'downloading'
|
|
})
|
|
|
|
# Build set of already-imported filenames for duplicate detection
|
|
# Only count as duplicate if the encrypted data file actually exists on disk
|
|
existing_filenames = set()
|
|
try:
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT storage_id, encrypted_filename FROM private_media WHERE encrypted_filename IS NOT NULL')
|
|
for row in cursor.fetchall():
|
|
try:
|
|
decrypted_name = crypto.decrypt_field(row['encrypted_filename'])
|
|
if decrypted_name:
|
|
enc_file = data_path / f"{row['storage_id']}.enc"
|
|
if enc_file.exists():
|
|
existing_filenames.add(decrypted_name)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
for idx, url in enumerate(urls):
|
|
url_basename = url.rstrip('/').split('/')[-1].split('?')[0] or f'url-{idx + 1}'
|
|
|
|
_update_pg_job(job_id, {
|
|
'current_file': url_basename,
|
|
'current_phase': 'downloading',
|
|
'bytes_downloaded': 0,
|
|
'bytes_total': 0,
|
|
'processed_files': idx
|
|
})
|
|
|
|
# Check for duplicate filename
|
|
if url_basename in existing_filenames:
|
|
duplicate_count += 1
|
|
results.append({
|
|
'filename': url_basename,
|
|
'status': 'duplicate',
|
|
'reason': 'File already exists'
|
|
})
|
|
logger.info(f"Skipping duplicate file: {url_basename}")
|
|
continue
|
|
|
|
temp_dir = Path(tempfile.mkdtemp(prefix='pg_import_'))
|
|
downloaded_files = []
|
|
|
|
try:
|
|
if url_referers.get(url) == '__ytdlp__':
|
|
# Download via yt-dlp (handles HLS/DASH streams, format merging)
|
|
import subprocess
|
|
_update_pg_job(job_id, {'current_phase': 'downloading', 'current_file': url_basename})
|
|
yt_result = subprocess.run(
|
|
[f'{_VENV_BIN}/yt-dlp', '-o', str(temp_dir / '%(title).100B.%(ext)s'),
|
|
'--no-playlist', '--no-warnings', '--no-progress', url],
|
|
capture_output=True, text=True, timeout=600
|
|
)
|
|
if yt_result.returncode != 0:
|
|
raise Exception(f'yt-dlp failed: {yt_result.stderr[:200]}')
|
|
downloaded_files = [f for f in temp_dir.iterdir() if f.is_file() and not f.name.startswith('.')]
|
|
if not downloaded_files:
|
|
raise Exception('yt-dlp produced no output files')
|
|
|
|
# Try file host downloader
|
|
elif (host := downloader.detect_host(url)):
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
result = loop.run_until_complete(downloader.download_url(url, temp_dir))
|
|
finally:
|
|
loop.close()
|
|
if result.get('success') and result.get('files'):
|
|
downloaded_files = [Path(f) for f in result['files']]
|
|
else:
|
|
raise Exception(result.get('error', 'Download failed'))
|
|
else:
|
|
# Direct download - check for domain-specific auth
|
|
import_auth = _get_import_auth_for_url(db, crypto, url)
|
|
|
|
base_headers = {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
}
|
|
if url in url_referers:
|
|
base_headers['Referer'] = url_referers[url]
|
|
base_kwargs = {}
|
|
|
|
if import_auth:
|
|
if import_auth.get('user_agent'):
|
|
base_headers['User-Agent'] = import_auth['user_agent']
|
|
if import_auth.get('username') and import_auth.get('password'):
|
|
base_kwargs['auth'] = (import_auth['username'], import_auth['password'])
|
|
if import_auth.get('cookies'):
|
|
base_kwargs['cookies'] = {c['name']: c['value'] for c in import_auth['cookies']}
|
|
|
|
# HEAD request to get file size and check range support
|
|
cf_fallback = False
|
|
try:
|
|
head_resp = req_lib.head(url, headers=base_headers, timeout=30,
|
|
allow_redirects=True, **base_kwargs)
|
|
head_resp.raise_for_status()
|
|
except Exception as head_err:
|
|
status_code = getattr(getattr(head_err, 'response', None), 'status_code', 0)
|
|
if status_code == 403 or '403' in str(head_err):
|
|
# Cloudflare-protected - fall back to curl_cffi
|
|
logger.info(f"HEAD request got 403, falling back to curl_cffi for {url}")
|
|
cf_fallback = True
|
|
else:
|
|
raise
|
|
|
|
if cf_fallback:
|
|
from curl_cffi import requests as cf_requests
|
|
cf_resp = cf_requests.get(url, impersonate='chrome', timeout=120)
|
|
cf_resp.raise_for_status()
|
|
ct = cf_resp.headers.get('Content-Type', '').lower()
|
|
if not (ct.startswith('image/') or ct.startswith('video/') or ct == 'application/octet-stream'):
|
|
raise Exception(f'Unsupported content type: {ct}')
|
|
filename = url_basename
|
|
save_path = temp_dir / filename
|
|
save_path.write_bytes(cf_resp.content)
|
|
downloaded_files = [save_path]
|
|
_update_pg_job(job_id, {
|
|
'current_phase': 'processing',
|
|
'bytes_downloaded': len(cf_resp.content),
|
|
'bytes_total': len(cf_resp.content)
|
|
})
|
|
else:
|
|
content_type = head_resp.headers.get('Content-Type', '').lower()
|
|
if not (content_type.startswith('image/') or content_type.startswith('video/') or content_type == 'application/octet-stream'):
|
|
raise Exception(f'Unsupported content type: {content_type}')
|
|
|
|
# Determine filename from URL or content-disposition
|
|
filename = url_basename
|
|
cd = head_resp.headers.get('Content-Disposition', '')
|
|
if 'filename=' in cd:
|
|
fn_match = re.search(r'filename="?([^";\n]+)"?', cd)
|
|
if fn_match:
|
|
filename = fn_match.group(1).strip()
|
|
|
|
bytes_total = int(head_resp.headers.get('Content-Length', 0) or 0)
|
|
accepts_ranges = head_resp.headers.get('Accept-Ranges', '').lower() == 'bytes'
|
|
save_path = temp_dir / filename
|
|
|
|
if bytes_total:
|
|
_update_pg_job(job_id, {'bytes_total': bytes_total})
|
|
|
|
NUM_THREADS = 5
|
|
MAX_RETRIES = 3
|
|
STALL_TIMEOUT = 30 # seconds with no data = stalled
|
|
|
|
if accepts_ranges and bytes_total > 0:
|
|
# Multi-threaded segmented download with stall detection and retry
|
|
import threading
|
|
|
|
segment_size = bytes_total // NUM_THREADS
|
|
segments = []
|
|
for i in range(NUM_THREADS):
|
|
start = i * segment_size
|
|
end = bytes_total - 1 if i == NUM_THREADS - 1 else (i + 1) * segment_size - 1
|
|
segments.append((start, end))
|
|
|
|
segment_files = [temp_dir / f".part{i}" for i in range(NUM_THREADS)]
|
|
segment_progress = [0] * NUM_THREADS
|
|
segment_errors = [None] * NUM_THREADS
|
|
progress_lock = threading.Lock()
|
|
|
|
def download_segment(seg_idx, byte_start, byte_end, out_path):
|
|
expected_size = byte_end - byte_start + 1
|
|
bytes_written = 0
|
|
for attempt in range(MAX_RETRIES):
|
|
try:
|
|
resume_start = byte_start + bytes_written
|
|
if resume_start > byte_end:
|
|
break
|
|
seg_headers = {**base_headers, 'Range': f'bytes={resume_start}-{byte_end}'}
|
|
r = req_lib.get(url, headers=seg_headers, stream=True,
|
|
timeout=(30, STALL_TIMEOUT), **base_kwargs)
|
|
r.raise_for_status()
|
|
|
|
# Verify server honored the Range request
|
|
if r.status_code != 206:
|
|
raise Exception(f"Server returned {r.status_code} instead of 206 for range request")
|
|
|
|
mode = 'ab' if bytes_written > 0 else 'wb'
|
|
with open(out_path, mode) as f:
|
|
for chunk in r.iter_content(chunk_size=65536):
|
|
f.write(chunk)
|
|
bytes_written += len(chunk)
|
|
with progress_lock:
|
|
segment_progress[seg_idx] = bytes_written
|
|
|
|
# Verify segment received the expected number of bytes
|
|
if bytes_written != expected_size:
|
|
raise Exception(f"Segment size mismatch: expected {expected_size} bytes, got {bytes_written}")
|
|
|
|
break # Success
|
|
except Exception as e:
|
|
if attempt < MAX_RETRIES - 1:
|
|
logger.warning(f"Segment {seg_idx} stalled/failed (attempt {attempt+1}), retrying from byte {byte_start + bytes_written}: {e}")
|
|
time.sleep(2 ** attempt)
|
|
else:
|
|
segment_errors[seg_idx] = e
|
|
|
|
threads = []
|
|
for i, (start, end) in enumerate(segments):
|
|
t = threading.Thread(target=download_segment,
|
|
args=(i, start, end, segment_files[i]))
|
|
t.start()
|
|
threads.append(t)
|
|
|
|
# Monitor progress while threads run
|
|
while any(t.is_alive() for t in threads):
|
|
time.sleep(0.3)
|
|
with progress_lock:
|
|
total_downloaded = sum(segment_progress)
|
|
_update_pg_job(job_id, {'bytes_downloaded': total_downloaded})
|
|
|
|
for t in threads:
|
|
t.join()
|
|
|
|
# Check for errors
|
|
for i, err in enumerate(segment_errors):
|
|
if err:
|
|
raise Exception(f'Segment {i} failed after {MAX_RETRIES} attempts: {err}')
|
|
|
|
# Combine segments into final file
|
|
with open(save_path, 'wb') as fout:
|
|
for seg_file in segment_files:
|
|
with open(seg_file, 'rb') as fin:
|
|
while True:
|
|
chunk = fin.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
fout.write(chunk)
|
|
seg_file.unlink()
|
|
|
|
# Verify combined file size matches expected total
|
|
actual_size = save_path.stat().st_size
|
|
if actual_size != bytes_total:
|
|
save_path.unlink(missing_ok=True)
|
|
raise Exception(f'Downloaded file size mismatch: expected {bytes_total} bytes, got {actual_size}')
|
|
|
|
_update_pg_job(job_id, {'bytes_downloaded': bytes_total})
|
|
else:
|
|
# Single-threaded download with stall detection and retry
|
|
bytes_downloaded = 0
|
|
last_progress_update = 0
|
|
for attempt in range(MAX_RETRIES):
|
|
try:
|
|
dl_headers = {**base_headers}
|
|
if bytes_downloaded > 0 and accepts_ranges:
|
|
dl_headers['Range'] = f'bytes={bytes_downloaded}-'
|
|
resp = req_lib.get(url, headers=dl_headers, stream=True,
|
|
timeout=(30, STALL_TIMEOUT), **base_kwargs)
|
|
resp.raise_for_status()
|
|
|
|
# On resume, verify server honored the Range request
|
|
if bytes_downloaded > 0 and accepts_ranges and resp.status_code != 206:
|
|
raise Exception(f"Server returned {resp.status_code} instead of 206 for range resume")
|
|
|
|
mode = 'ab' if bytes_downloaded > 0 else 'wb'
|
|
with open(save_path, mode) as f:
|
|
for chunk in resp.iter_content(chunk_size=65536):
|
|
f.write(chunk)
|
|
bytes_downloaded += len(chunk)
|
|
if bytes_downloaded - last_progress_update >= 262144:
|
|
_update_pg_job(job_id, {'bytes_downloaded': bytes_downloaded})
|
|
last_progress_update = bytes_downloaded
|
|
break # Success
|
|
except Exception as e:
|
|
if attempt < MAX_RETRIES - 1:
|
|
logger.warning(f"Download stalled/failed (attempt {attempt+1}), retrying from byte {bytes_downloaded}: {e}")
|
|
time.sleep(2 ** attempt)
|
|
else:
|
|
raise
|
|
|
|
# Verify final file size if Content-Length was known
|
|
if bytes_total > 0:
|
|
actual_size = save_path.stat().st_size
|
|
if actual_size != bytes_total:
|
|
save_path.unlink(missing_ok=True)
|
|
raise Exception(f'Downloaded file size mismatch: expected {bytes_total} bytes, got {actual_size}')
|
|
|
|
_update_pg_job(job_id, {'bytes_downloaded': bytes_downloaded})
|
|
|
|
downloaded_files = [save_path]
|
|
|
|
# Update phase to processing
|
|
_update_pg_job(job_id, {'current_phase': 'processing'})
|
|
|
|
# Process each downloaded file
|
|
for dl_file in downloaded_files:
|
|
dl_filename = dl_file.name
|
|
|
|
try:
|
|
file_hash = _get_file_hash(dl_file)
|
|
|
|
# Duplicate check (scoped by person)
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
if person_id:
|
|
cursor.execute('SELECT id, person_id FROM private_media WHERE file_hash = ? AND person_id = ?', (file_hash, person_id))
|
|
else:
|
|
cursor.execute('SELECT id, person_id FROM private_media WHERE file_hash = ?', (file_hash,))
|
|
existing = cursor.fetchone()
|
|
|
|
if existing:
|
|
duplicate_count += 1
|
|
results.append({
|
|
'filename': dl_filename,
|
|
'status': 'duplicate',
|
|
'existing_id': existing['id']
|
|
})
|
|
continue
|
|
|
|
file_info = _get_file_info(dl_file)
|
|
file_size = dl_file.stat().st_size
|
|
|
|
# Skip low-resolution images
|
|
min_res = int(config.get('min_import_resolution', 0) or 0)
|
|
if min_res > 0 and file_info['file_type'] == 'image':
|
|
w = file_info.get('width') or 0
|
|
h = file_info.get('height') or 0
|
|
if w < min_res or h < min_res:
|
|
skipped_count += 1
|
|
results.append({
|
|
'filename': dl_filename,
|
|
'status': 'skipped',
|
|
'reason': f'Low resolution ({w}x{h}, min {min_res}px)'
|
|
})
|
|
continue
|
|
|
|
# Compute perceptual hash before encryption
|
|
perceptual_hash = _compute_perceptual_hash(dl_file)
|
|
|
|
# Extract date (EXIF first, then filename, then fallback)
|
|
item_date = _extract_date_from_exif(dl_file)
|
|
if not item_date:
|
|
item_date = _extract_date_from_filename(dl_filename)
|
|
if not item_date:
|
|
item_date = final_date
|
|
|
|
storage_id = str(uuid.uuid4())
|
|
|
|
# Generate thumbnail
|
|
_update_pg_job(job_id, {'current_phase': 'thumbnail'})
|
|
temp_thumb = temp_dir / f"pg_thumb_{storage_id}.jpg"
|
|
_generate_thumbnail(dl_file, temp_thumb, file_info['file_type'])
|
|
|
|
# Encrypt original
|
|
_update_pg_job(job_id, {'current_phase': 'encrypting'})
|
|
encrypted_file = data_path / f"{storage_id}.enc"
|
|
if not crypto.encrypt_file(dl_file, encrypted_file):
|
|
raise Exception(f"Encryption failed for {dl_filename}")
|
|
|
|
# Encrypt thumbnail
|
|
if temp_thumb.exists():
|
|
encrypted_thumb = thumbs_path / f"{storage_id}.enc"
|
|
crypto.encrypt_file(temp_thumb, encrypted_thumb)
|
|
|
|
# Insert into database
|
|
item_post_id = url_post_ids.get(url, post_id)
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
INSERT INTO private_media (
|
|
post_id, storage_id, encrypted_filename, encrypted_description,
|
|
file_hash, file_size, file_type, mime_type,
|
|
width, height, duration, person_id,
|
|
encrypted_media_date, source_type, encrypted_source_path,
|
|
perceptual_hash
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
''', (
|
|
item_post_id,
|
|
storage_id,
|
|
crypto.encrypt_field(dl_filename),
|
|
None,
|
|
file_hash,
|
|
file_size,
|
|
file_info['file_type'],
|
|
file_info['mime_type'],
|
|
file_info['width'],
|
|
file_info['height'],
|
|
file_info['duration'],
|
|
person_id,
|
|
crypto.encrypt_field(item_date),
|
|
'import',
|
|
crypto.encrypt_field(url),
|
|
perceptual_hash
|
|
))
|
|
media_id = cursor.lastrowid
|
|
imported_media_ids.append(media_id)
|
|
|
|
for tag_id in tag_id_list:
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO private_media_tags (media_id, tag_id)
|
|
VALUES (?, ?)
|
|
''', (media_id, tag_id))
|
|
|
|
conn.commit()
|
|
|
|
success_count += 1
|
|
existing_filenames.add(dl_filename)
|
|
results.append({
|
|
'id': media_id,
|
|
'filename': dl_filename,
|
|
'status': 'created',
|
|
'media_date': item_date
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Import processing failed for {dl_filename}: {e}")
|
|
failed_count += 1
|
|
results.append({
|
|
'filename': dl_filename,
|
|
'status': 'failed',
|
|
'error': str(e)
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Import download failed for {url}: {e}")
|
|
failed_count += 1
|
|
results.append({
|
|
'filename': url_basename,
|
|
'status': 'failed',
|
|
'error': str(e)
|
|
})
|
|
|
|
finally:
|
|
# Clean up temp dir
|
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
|
|
_update_pg_job(job_id, {
|
|
'results': list(results),
|
|
'success_count': success_count,
|
|
'failed_count': failed_count,
|
|
'duplicate_count': duplicate_count,
|
|
'skipped_count': skipped_count,
|
|
'processed_files': idx + 1
|
|
})
|
|
|
|
# Delete empty posts (original + any thread posts with no successful imports)
|
|
all_post_ids = set()
|
|
if post_id:
|
|
all_post_ids.add(post_id)
|
|
all_post_ids.update(url_post_ids.values())
|
|
used_post_ids = set()
|
|
if imported_media_ids:
|
|
try:
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
placeholders = ','.join('?' for _ in imported_media_ids)
|
|
cursor.execute(f'SELECT DISTINCT post_id FROM private_media WHERE id IN ({placeholders})', imported_media_ids)
|
|
used_post_ids = {row['post_id'] for row in cursor.fetchall() if row['post_id']}
|
|
except Exception:
|
|
pass
|
|
empty_post_ids = all_post_ids - used_post_ids
|
|
if empty_post_ids:
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
for pid in empty_post_ids:
|
|
cursor.execute('DELETE FROM private_media_post_tags WHERE post_id = ?', (pid,))
|
|
cursor.execute('DELETE FROM private_media_posts WHERE id = ?', (pid,))
|
|
conn.commit()
|
|
|
|
_invalidate_posts_cache()
|
|
_update_pg_job(job_id, {
|
|
'status': 'completed',
|
|
'completed_at': datetime.now().isoformat(),
|
|
'current_file': None,
|
|
'post_id': post_id if imported_media_ids else None
|
|
})
|
|
|
|
|
|
@router.post("/import-urls")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def import_urls(
|
|
request: Request,
|
|
body: ImportUrlRequest,
|
|
background_tasks: BackgroundTasks,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Import media from URLs (file hosts or direct links)."""
|
|
if not body.urls:
|
|
raise ValidationError("At least one URL is required")
|
|
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
# Determine the post-level date
|
|
post_date = body.media_date or date.today().isoformat()
|
|
|
|
# Create a post to group imported media
|
|
post_id = None
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
INSERT INTO private_media_posts (
|
|
person_id, encrypted_description, encrypted_media_date
|
|
) VALUES (?, ?, ?)
|
|
''', (
|
|
body.person_id,
|
|
crypto.encrypt_field(body.description) if body.description else None,
|
|
crypto.encrypt_field(post_date)
|
|
))
|
|
post_id = cursor.lastrowid
|
|
|
|
for tag_id in body.tag_ids:
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO private_media_post_tags (post_id, tag_id)
|
|
VALUES (?, ?)
|
|
''', (post_id, tag_id))
|
|
|
|
conn.commit()
|
|
|
|
# Create job and launch background task
|
|
job_id = f"pg_import_{uuid.uuid4().hex[:12]}"
|
|
_create_pg_job(job_id, len(body.urls), 'import')
|
|
|
|
background_tasks.add_task(
|
|
_import_urls_to_gallery_background,
|
|
job_id,
|
|
body.urls,
|
|
post_id,
|
|
body.person_id,
|
|
body.tag_ids,
|
|
post_date,
|
|
body.description
|
|
)
|
|
|
|
return {
|
|
"job_id": job_id,
|
|
"post_id": post_id,
|
|
"status": "processing",
|
|
"total_files": len(body.urls)
|
|
}
|
|
|
|
|
|
def _copy_to_gallery_background(job_id, source_paths, post_id, person_id, tag_ids, media_date, original_filenames, post_date):
|
|
"""Background task to copy files to the private gallery."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
config = _get_config(db)
|
|
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
data_path = storage_path / 'data'
|
|
thumbs_path = storage_path / 'thumbs'
|
|
data_path.mkdir(parents=True, exist_ok=True)
|
|
thumbs_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
results = []
|
|
copied_media_ids = []
|
|
success_count = 0
|
|
failed_count = 0
|
|
duplicate_count = 0
|
|
skipped_count = 0
|
|
|
|
for idx, source_path_str in enumerate(source_paths):
|
|
source_path = Path(source_path_str)
|
|
filename = original_filenames.get(source_path_str, source_path.name) if original_filenames else source_path.name
|
|
|
|
_update_pg_job(job_id, {
|
|
'current_file': filename,
|
|
'processed_files': idx
|
|
})
|
|
|
|
if not source_path.exists():
|
|
failed_count += 1
|
|
results.append({
|
|
'path': source_path_str,
|
|
'filename': filename,
|
|
'status': 'failed',
|
|
'error': 'File not found'
|
|
})
|
|
_update_pg_job(job_id, {
|
|
'results': list(results),
|
|
'failed_count': failed_count,
|
|
'processed_files': idx + 1
|
|
})
|
|
continue
|
|
|
|
try:
|
|
# Calculate hash for duplicate detection
|
|
file_hash = _get_file_hash(source_path)
|
|
|
|
# Check for duplicates (scoped by person)
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
if person_id:
|
|
cursor.execute('SELECT id, person_id FROM private_media WHERE file_hash = ? AND person_id = ?', (file_hash, person_id))
|
|
else:
|
|
cursor.execute('SELECT id, person_id FROM private_media WHERE file_hash = ?', (file_hash,))
|
|
existing = cursor.fetchone()
|
|
|
|
if existing:
|
|
duplicate_count += 1
|
|
results.append({
|
|
'path': source_path_str,
|
|
'filename': filename,
|
|
'status': 'duplicate',
|
|
'existing_id': existing['id']
|
|
})
|
|
_update_pg_job(job_id, {
|
|
'results': list(results),
|
|
'duplicate_count': duplicate_count,
|
|
'processed_files': idx + 1
|
|
})
|
|
continue
|
|
|
|
# Get file info
|
|
file_info = _get_file_info(source_path)
|
|
file_size = source_path.stat().st_size
|
|
|
|
# Skip low-resolution images
|
|
min_res = int(config.get('min_import_resolution', 0) or 0)
|
|
if min_res > 0 and file_info['file_type'] == 'image':
|
|
w = file_info.get('width') or 0
|
|
h = file_info.get('height') or 0
|
|
if w < min_res or h < min_res:
|
|
skipped_count += 1
|
|
results.append({
|
|
'path': source_path_str,
|
|
'filename': filename,
|
|
'status': 'skipped',
|
|
'reason': f'Low resolution ({w}x{h}, min {min_res}px)'
|
|
})
|
|
_update_pg_job(job_id, {
|
|
'results': list(results),
|
|
'skipped_count': skipped_count,
|
|
'processed_files': idx + 1
|
|
})
|
|
continue
|
|
|
|
# Compute perceptual hash before encryption
|
|
perceptual_hash = _compute_perceptual_hash(source_path)
|
|
|
|
# Determine media date per file
|
|
final_date = media_date
|
|
if not final_date:
|
|
final_date = _extract_date_from_exif(source_path)
|
|
if not final_date:
|
|
final_date = _extract_date_from_filename(source_path.name)
|
|
if not final_date:
|
|
try:
|
|
from datetime import datetime as dt
|
|
mtime = source_path.stat().st_mtime
|
|
final_date = dt.fromtimestamp(mtime).strftime('%Y-%m-%dT%H:%M:%S')
|
|
except Exception:
|
|
pass
|
|
if not final_date:
|
|
final_date = post_date
|
|
|
|
# Generate storage ID
|
|
storage_id = str(uuid.uuid4())
|
|
|
|
# Generate thumbnail first
|
|
temp_dir = Path(tempfile.gettempdir())
|
|
temp_thumb = temp_dir / f"pg_thumb_{storage_id}.jpg"
|
|
_generate_thumbnail(source_path, temp_thumb, file_info['file_type'])
|
|
|
|
# Encrypt the original file
|
|
encrypted_file = data_path / f"{storage_id}.enc"
|
|
if not crypto.encrypt_file(source_path, encrypted_file):
|
|
raise Exception("Encryption failed")
|
|
|
|
# Encrypt thumbnail if it exists
|
|
if temp_thumb.exists():
|
|
encrypted_thumb = thumbs_path / f"{storage_id}.enc"
|
|
crypto.encrypt_file(temp_thumb, encrypted_thumb)
|
|
temp_thumb.unlink()
|
|
|
|
# Insert into database with post_id reference
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
INSERT INTO private_media (
|
|
post_id, storage_id, encrypted_filename, encrypted_description,
|
|
file_hash, file_size, file_type, mime_type,
|
|
width, height, duration, person_id,
|
|
encrypted_media_date, source_type, encrypted_source_path,
|
|
perceptual_hash
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
''', (
|
|
post_id,
|
|
storage_id,
|
|
crypto.encrypt_field(filename),
|
|
None,
|
|
file_hash,
|
|
file_size,
|
|
file_info['file_type'],
|
|
file_info['mime_type'],
|
|
file_info['width'],
|
|
file_info['height'],
|
|
file_info['duration'],
|
|
person_id,
|
|
crypto.encrypt_field(final_date),
|
|
'copy',
|
|
crypto.encrypt_field(source_path_str),
|
|
perceptual_hash
|
|
))
|
|
media_id = cursor.lastrowid
|
|
copied_media_ids.append(media_id)
|
|
|
|
for tag_id in tag_ids:
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO private_media_tags (media_id, tag_id)
|
|
VALUES (?, ?)
|
|
''', (media_id, tag_id))
|
|
|
|
conn.commit()
|
|
|
|
success_count += 1
|
|
results.append({
|
|
'id': media_id,
|
|
'path': source_path_str,
|
|
'filename': filename,
|
|
'status': 'created',
|
|
'media_date': final_date
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Copy failed for {source_path_str}: {e}")
|
|
failed_count += 1
|
|
results.append({
|
|
'path': source_path_str,
|
|
'filename': filename,
|
|
'status': 'failed',
|
|
'error': str(e)
|
|
})
|
|
|
|
_update_pg_job(job_id, {
|
|
'results': list(results),
|
|
'success_count': success_count,
|
|
'failed_count': failed_count,
|
|
'duplicate_count': duplicate_count,
|
|
'skipped_count': skipped_count,
|
|
'processed_files': idx + 1
|
|
})
|
|
|
|
# If no files were successfully copied, delete the empty post
|
|
if not copied_media_ids and post_id:
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('DELETE FROM private_media_post_tags WHERE post_id = ?', (post_id,))
|
|
cursor.execute('DELETE FROM private_media_posts WHERE id = ?', (post_id,))
|
|
conn.commit()
|
|
|
|
_invalidate_posts_cache()
|
|
_update_pg_job(job_id, {
|
|
'status': 'completed',
|
|
'completed_at': datetime.now().isoformat(),
|
|
'current_file': None,
|
|
'post_id': post_id if copied_media_ids else None
|
|
})
|
|
|
|
|
|
@router.post("/copy")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def copy_to_gallery(
|
|
request: Request,
|
|
body: CopyRequest,
|
|
background_tasks: BackgroundTasks,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Copy files from other locations to the gallery."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
# Determine the post-level date
|
|
post_date = body.media_date or date.today().isoformat()
|
|
|
|
# Create a post to group copied media
|
|
post_id = None
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
INSERT INTO private_media_posts (
|
|
person_id, encrypted_description, encrypted_media_date
|
|
) VALUES (?, ?, ?)
|
|
''', (
|
|
body.person_id,
|
|
crypto.encrypt_field(body.description) if body.description else None,
|
|
crypto.encrypt_field(post_date)
|
|
))
|
|
post_id = cursor.lastrowid
|
|
|
|
for tag_id in body.tag_ids:
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO private_media_post_tags (post_id, tag_id)
|
|
VALUES (?, ?)
|
|
''', (post_id, tag_id))
|
|
|
|
conn.commit()
|
|
|
|
# Create job and launch background task
|
|
job_id = f"pg_copy_{uuid.uuid4().hex[:12]}"
|
|
_create_pg_job(job_id, len(body.source_paths), 'copy')
|
|
|
|
background_tasks.add_task(
|
|
_copy_to_gallery_background,
|
|
job_id,
|
|
body.source_paths,
|
|
post_id,
|
|
body.person_id,
|
|
body.tag_ids,
|
|
body.media_date,
|
|
body.original_filenames or {},
|
|
post_date
|
|
)
|
|
|
|
return {
|
|
"job_id": job_id,
|
|
"post_id": post_id,
|
|
"status": "processing",
|
|
"total_files": len(body.source_paths)
|
|
}
|
|
|
|
|
|
@router.post("/import-directory")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def import_directory(
|
|
request: Request,
|
|
body: ImportDirectoryRequest,
|
|
background_tasks: BackgroundTasks,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Import all media files from a server directory into the gallery."""
|
|
dir_path = Path(body.directory_path)
|
|
|
|
if not dir_path.exists():
|
|
raise HTTPException(status_code=400, detail=f"Directory not found: {body.directory_path}")
|
|
if not dir_path.is_dir():
|
|
raise HTTPException(status_code=400, detail=f"Path is not a directory: {body.directory_path}")
|
|
|
|
# Collect media files
|
|
media_extensions = {
|
|
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.avif',
|
|
'.mkv', '.avi', '.wmv', '.flv', '.webm', '.mov', '.mp4', '.m4v', '.ts'
|
|
}
|
|
|
|
source_paths = []
|
|
if body.recursive:
|
|
for root, _dirs, filenames in os.walk(dir_path):
|
|
for fname in sorted(filenames):
|
|
if Path(fname).suffix.lower() in media_extensions:
|
|
source_paths.append(str(Path(root) / fname))
|
|
else:
|
|
for entry in sorted(dir_path.iterdir()):
|
|
if entry.is_file() and entry.suffix.lower() in media_extensions:
|
|
source_paths.append(str(entry))
|
|
|
|
if not source_paths:
|
|
raise HTTPException(status_code=400, detail="No media files found in the specified directory")
|
|
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
# Determine the post-level date
|
|
post_date = body.media_date or date.today().isoformat()
|
|
|
|
# Create a post to group imported media
|
|
post_id = None
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
INSERT INTO private_media_posts (
|
|
person_id, encrypted_description, encrypted_media_date
|
|
) VALUES (?, ?, ?)
|
|
''', (
|
|
body.person_id,
|
|
crypto.encrypt_field(body.description) if body.description else None,
|
|
crypto.encrypt_field(post_date)
|
|
))
|
|
post_id = cursor.lastrowid
|
|
|
|
for tag_id in body.tag_ids:
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO private_media_post_tags (post_id, tag_id)
|
|
VALUES (?, ?)
|
|
''', (post_id, tag_id))
|
|
|
|
conn.commit()
|
|
|
|
# Create job and launch background task
|
|
job_id = f"pg_import_{uuid.uuid4().hex[:12]}"
|
|
_create_pg_job(job_id, len(source_paths), 'directory')
|
|
|
|
background_tasks.add_task(
|
|
_copy_to_gallery_background,
|
|
job_id,
|
|
source_paths,
|
|
post_id,
|
|
body.person_id,
|
|
body.tag_ids,
|
|
body.media_date,
|
|
{},
|
|
post_date
|
|
)
|
|
|
|
return {
|
|
"job_id": job_id,
|
|
"post_id": post_id,
|
|
"status": "processing",
|
|
"total_files": len(source_paths)
|
|
}
|
|
|
|
|
|
@router.get("/list-directory")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def list_directory(
|
|
request: Request,
|
|
path: str = Query(..., description="Server directory path to list"),
|
|
recursive: bool = Query(False, description="Recursively scan subdirectories"),
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""List media files in a server directory for preview before import."""
|
|
dir_path = Path(path)
|
|
|
|
if not dir_path.exists():
|
|
raise HTTPException(status_code=400, detail=f"Path not found: {path}")
|
|
if not dir_path.is_dir():
|
|
raise HTTPException(status_code=400, detail=f"Path is not a directory: {path}")
|
|
|
|
media_extensions = {
|
|
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.avif',
|
|
'.mkv', '.avi', '.wmv', '.flv', '.webm', '.mov', '.mp4', '.m4v', '.ts'
|
|
}
|
|
video_extensions = {'.mkv', '.avi', '.wmv', '.flv', '.webm', '.mov', '.mp4', '.m4v', '.ts'}
|
|
|
|
files = []
|
|
subdirectories = []
|
|
total_size = 0
|
|
|
|
try:
|
|
if recursive:
|
|
for root, dirs, filenames in os.walk(dir_path):
|
|
rel_root = Path(root).relative_to(dir_path)
|
|
if root == str(dir_path):
|
|
subdirectories.extend(sorted(dirs))
|
|
for fname in sorted(filenames):
|
|
fpath = Path(root) / fname
|
|
if fpath.suffix.lower() in media_extensions:
|
|
size = fpath.stat().st_size
|
|
total_size += size
|
|
display_name = str(rel_root / fname) if str(rel_root) != '.' else fname
|
|
files.append({
|
|
'name': display_name,
|
|
'size': size,
|
|
'type': 'video' if fpath.suffix.lower() in video_extensions else 'image'
|
|
})
|
|
else:
|
|
for entry in sorted(dir_path.iterdir()):
|
|
if entry.is_dir():
|
|
subdirectories.append(entry.name)
|
|
elif entry.is_file() and entry.suffix.lower() in media_extensions:
|
|
size = entry.stat().st_size
|
|
total_size += size
|
|
files.append({
|
|
'name': entry.name,
|
|
'size': size,
|
|
'type': 'video' if entry.suffix.lower() in video_extensions else 'image'
|
|
})
|
|
except PermissionError:
|
|
raise HTTPException(status_code=403, detail=f"Permission denied: {path}")
|
|
|
|
return {
|
|
"path": path,
|
|
"files": files,
|
|
"subdirectories": subdirectories,
|
|
"file_count": len(files),
|
|
"total_size": total_size
|
|
}
|
|
|
|
|
|
@router.post("/regenerate-thumbnails")
|
|
@limiter.limit("5/minute")
|
|
@handle_exceptions
|
|
async def regenerate_thumbnails(
|
|
request: Request,
|
|
missing_only: bool = Query(False, description="Only regenerate missing thumbnails"),
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Regenerate thumbnails. Use missing_only=true to only fix missing ones."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
config = _get_config(db)
|
|
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
data_path = storage_path / 'data'
|
|
thumbs_path = storage_path / 'thumbs'
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
if missing_only:
|
|
cursor.execute("SELECT id, storage_id, file_type, mime_type FROM private_media")
|
|
else:
|
|
cursor.execute("SELECT id, storage_id, file_type, mime_type FROM private_media WHERE file_type = 'image'")
|
|
media_rows = cursor.fetchall()
|
|
|
|
fixed = 0
|
|
skipped = 0
|
|
errors = []
|
|
temp_dir = Path(tempfile.gettempdir())
|
|
|
|
for row in media_rows:
|
|
storage_id = row['storage_id']
|
|
file_type = (row['file_type'] or 'image') if missing_only else 'image'
|
|
encrypted_file = data_path / f"{storage_id}.enc"
|
|
encrypted_thumb = thumbs_path / f"{storage_id}.enc"
|
|
|
|
if not encrypted_file.exists():
|
|
skipped += 1
|
|
continue
|
|
|
|
if missing_only and encrypted_thumb.exists():
|
|
skipped += 1
|
|
continue
|
|
|
|
try:
|
|
# Determine file extension from mime_type
|
|
mime = row['mime_type'] or ''
|
|
ext = mimetypes.guess_extension(mime) or ('.mp4' if file_type == 'video' else '.jpg')
|
|
if ext == '.jpe':
|
|
ext = '.jpg'
|
|
|
|
# Decrypt original to temp file (with extension so PIL/ffmpeg can identify it)
|
|
temp_original = temp_dir / f"pg_regen_{storage_id}{ext}"
|
|
crypto.decrypt_file(encrypted_file, temp_original)
|
|
|
|
# Generate thumbnail
|
|
temp_thumb = temp_dir / f"pg_thumb_{storage_id}.jpg"
|
|
if _generate_thumbnail(temp_original, temp_thumb, file_type):
|
|
crypto.encrypt_file(temp_thumb, encrypted_thumb)
|
|
fixed += 1
|
|
else:
|
|
errors.append({'id': row['id'], 'error': f'Thumbnail generation returned false ({file_type})'})
|
|
|
|
# Clean up temp files
|
|
if temp_original.exists():
|
|
temp_original.unlink()
|
|
if temp_thumb.exists():
|
|
temp_thumb.unlink()
|
|
|
|
except Exception as e:
|
|
errors.append({'id': row['id'], 'error': str(e)})
|
|
for f in [temp_dir / f"pg_regen_{storage_id}{ext}", temp_dir / f"pg_thumb_{storage_id}.jpg"]:
|
|
if f.exists():
|
|
f.unlink()
|
|
|
|
# Clear thumbnail cache
|
|
with _thumb_cache_lock:
|
|
_thumb_cache.clear()
|
|
|
|
logger.info(f"Thumbnail regeneration complete: {fixed} regenerated, {skipped} skipped, {len(errors)} errors")
|
|
return {
|
|
'total': len(media_rows),
|
|
'regenerated': fixed,
|
|
'skipped': skipped,
|
|
'errors': errors
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# FILE SERVING ENDPOINTS
|
|
# ============================================================================
|
|
|
|
@router.get("/thumbnail/{media_id}")
|
|
@limiter.limit("300/minute")
|
|
@handle_exceptions
|
|
async def get_thumbnail(
|
|
request: Request,
|
|
media_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get decrypted thumbnail with in-memory LRU cache and ETag support."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
config = _get_config(db)
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT storage_id FROM private_media WHERE id = ?', (media_id,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
raise NotFoundError(f"Media {media_id} not found")
|
|
|
|
storage_id = row['storage_id']
|
|
|
|
# Check in-memory cache first (avoids disk I/O + AES decrypt)
|
|
cached = _thumb_cache_get(storage_id)
|
|
if cached:
|
|
etag, decrypted = cached
|
|
# Check If-None-Match for 304
|
|
if_none_match = request.headers.get('if-none-match')
|
|
if if_none_match and if_none_match.strip('" ') == etag:
|
|
return Response(status_code=304, headers={
|
|
"ETag": f'"{etag}"',
|
|
"Cache-Control": "private, max-age=86400, stale-while-revalidate=604800",
|
|
})
|
|
return Response(
|
|
content=decrypted,
|
|
media_type="image/jpeg",
|
|
headers={
|
|
"Cache-Control": "private, max-age=86400, stale-while-revalidate=604800",
|
|
"ETag": f'"{etag}"',
|
|
}
|
|
)
|
|
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
thumb_file = storage_path / 'thumbs' / f"{storage_id}.enc"
|
|
|
|
if not thumb_file.exists():
|
|
raise NotFoundError("Thumbnail not available")
|
|
|
|
# Decrypt thumbnail
|
|
decrypted = crypto.decrypt_file_streaming(thumb_file)
|
|
if not decrypted:
|
|
raise NotFoundError("Failed to decrypt thumbnail")
|
|
|
|
# Generate ETag from content hash (fast — md5 of ~20KB)
|
|
etag = hashlib.md5(decrypted).hexdigest()
|
|
|
|
# Store in LRU cache
|
|
_thumb_cache_put(storage_id, etag, decrypted)
|
|
|
|
# Check If-None-Match
|
|
if_none_match = request.headers.get('if-none-match')
|
|
if if_none_match and if_none_match.strip('" ') == etag:
|
|
return Response(status_code=304, headers={
|
|
"ETag": f'"{etag}"',
|
|
"Cache-Control": "private, max-age=86400, stale-while-revalidate=604800",
|
|
})
|
|
|
|
return Response(
|
|
content=decrypted,
|
|
media_type="image/jpeg",
|
|
headers={
|
|
"Cache-Control": "private, max-age=86400, stale-while-revalidate=604800",
|
|
"ETag": f'"{etag}"',
|
|
}
|
|
)
|
|
|
|
|
|
@router.get("/file/{media_id}")
|
|
@limiter.limit("120/minute")
|
|
@handle_exceptions
|
|
async def get_file(
|
|
request: Request,
|
|
media_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get decrypted full file."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
config = _get_config(db)
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT storage_id, encrypted_filename, mime_type, file_size
|
|
FROM private_media WHERE id = ?
|
|
''', (media_id,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
raise NotFoundError(f"Media {media_id} not found")
|
|
|
|
storage_id = row['storage_id']
|
|
filename = crypto.decrypt_field(row['encrypted_filename'])
|
|
mime_type = row['mime_type']
|
|
file_size = row['file_size']
|
|
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
data_file = storage_path / 'data' / f"{storage_id}.enc"
|
|
|
|
if not data_file.exists():
|
|
raise NotFoundError("File not available")
|
|
|
|
# Use storage_id as ETag — file content never changes for a given storage_id
|
|
etag = storage_id
|
|
if_none_match = request.headers.get('if-none-match')
|
|
if if_none_match and if_none_match.strip('" ') == etag:
|
|
return Response(status_code=304, headers={
|
|
"ETag": f'"{etag}"',
|
|
"Cache-Control": "private, max-age=86400, stale-while-revalidate=604800",
|
|
})
|
|
|
|
# Sanitize filename for Content-Disposition header (remove quotes, non-ASCII)
|
|
from urllib.parse import quote
|
|
safe_filename = filename.replace('"', "'").encode('ascii', 'replace').decode('ascii')
|
|
encoded_filename = quote(filename, safe='')
|
|
headers = {
|
|
"Cache-Control": "private, max-age=86400, stale-while-revalidate=604800",
|
|
"Content-Disposition": f"inline; filename=\"{safe_filename}\"; filename*=UTF-8''{encoded_filename}",
|
|
"ETag": f'"{etag}"',
|
|
}
|
|
if file_size:
|
|
headers["Content-Length"] = str(file_size)
|
|
|
|
# Stream file chunks without loading all into memory
|
|
return StreamingResponse(
|
|
crypto.decrypt_file_generator(data_file),
|
|
media_type=mime_type,
|
|
headers=headers
|
|
)
|
|
|
|
|
|
@router.get("/stream/{media_id}")
|
|
@limiter.limit("120/minute")
|
|
@handle_exceptions
|
|
async def stream_video(
|
|
request: Request,
|
|
media_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Stream decrypted video file."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
config = _get_config(db)
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT storage_id, encrypted_filename, mime_type, file_size
|
|
FROM private_media WHERE id = ?
|
|
''', (media_id,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
raise NotFoundError(f"Media {media_id} not found")
|
|
|
|
storage_id = row['storage_id']
|
|
mime_type = row['mime_type']
|
|
file_size = row['file_size']
|
|
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
data_file = storage_path / 'data' / f"{storage_id}.enc"
|
|
|
|
if not data_file.exists():
|
|
raise NotFoundError("File not available")
|
|
|
|
total_size = file_size # Original plaintext size from DB
|
|
|
|
# Handle Range requests for video seeking
|
|
range_header = request.headers.get('range')
|
|
|
|
if range_header:
|
|
match = re.match(r'bytes=(\d+)-(\d*)', range_header)
|
|
if match:
|
|
start = int(match.group(1))
|
|
end = int(match.group(2)) if match.group(2) else total_size - 1
|
|
|
|
# Clamp values
|
|
start = min(start, total_size - 1)
|
|
end = min(end, total_size - 1)
|
|
content_length = end - start + 1
|
|
|
|
return StreamingResponse(
|
|
crypto.decrypt_file_range_generator(data_file, start, end),
|
|
status_code=206,
|
|
media_type=mime_type,
|
|
headers={
|
|
"Content-Range": f"bytes {start}-{end}/{total_size}",
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": str(content_length),
|
|
"Cache-Control": "private, max-age=3600"
|
|
}
|
|
)
|
|
|
|
# Full file request - stream chunks without loading all into memory
|
|
return StreamingResponse(
|
|
crypto.decrypt_file_generator(data_file),
|
|
media_type=mime_type,
|
|
headers={
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": str(total_size),
|
|
"Cache-Control": "private, max-age=3600"
|
|
}
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# BATCH OPERATIONS
|
|
# ============================================================================
|
|
|
|
@router.put("/media/batch/tags")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def batch_update_tags(
|
|
request: Request,
|
|
body: BatchTagsRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Add or remove tags from multiple items."""
|
|
db = _get_db()
|
|
|
|
added = 0
|
|
removed = 0
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
|
|
for media_id in body.media_ids:
|
|
# Add tags
|
|
if body.add_tag_ids:
|
|
for tag_id in body.add_tag_ids:
|
|
try:
|
|
cursor.execute('''
|
|
INSERT OR IGNORE INTO private_media_tags (media_id, tag_id)
|
|
VALUES (?, ?)
|
|
''', (media_id, tag_id))
|
|
added += cursor.rowcount
|
|
except Exception:
|
|
pass
|
|
|
|
# Remove tags
|
|
if body.remove_tag_ids:
|
|
for tag_id in body.remove_tag_ids:
|
|
cursor.execute('''
|
|
DELETE FROM private_media_tags
|
|
WHERE media_id = ? AND tag_id = ?
|
|
''', (media_id, tag_id))
|
|
removed += cursor.rowcount
|
|
|
|
conn.commit()
|
|
|
|
return {"added": added, "removed": removed}
|
|
|
|
|
|
@router.put("/media/batch/date")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def batch_update_date(
|
|
request: Request,
|
|
body: BatchDateRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Update date for multiple items."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
encrypted_date = crypto.encrypt_field(body.new_date)
|
|
updated = 0
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
for media_id in body.media_ids:
|
|
cursor.execute('''
|
|
UPDATE private_media
|
|
SET encrypted_media_date = ?, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
''', (encrypted_date, media_id))
|
|
updated += cursor.rowcount
|
|
conn.commit()
|
|
|
|
return {"updated": updated}
|
|
|
|
|
|
@router.put("/media/batch/person")
|
|
@limiter.limit("30/minute")
|
|
@handle_exceptions
|
|
async def batch_update_person(
|
|
request: Request,
|
|
body: BatchPersonRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Update person for multiple items."""
|
|
db = _get_db()
|
|
|
|
updated = 0
|
|
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
for media_id in body.media_ids:
|
|
cursor.execute('''
|
|
UPDATE private_media
|
|
SET person_id = ?, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
''', (body.person_id, media_id))
|
|
updated += cursor.rowcount
|
|
conn.commit()
|
|
|
|
return {"updated": updated}
|
|
|
|
|
|
# ============================================================================
|
|
# EXPORT ENDPOINTS
|
|
# ============================================================================
|
|
|
|
@router.get("/export/{media_id}")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def export_single(
|
|
request: Request,
|
|
media_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Export a single file (decrypted with original filename)."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
config = _get_config(db)
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT storage_id, encrypted_filename, mime_type
|
|
FROM private_media WHERE id = ?
|
|
''', (media_id,))
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
raise NotFoundError(f"Media {media_id} not found")
|
|
|
|
storage_id = row['storage_id']
|
|
filename = crypto.decrypt_field(row['encrypted_filename'])
|
|
mime_type = row['mime_type']
|
|
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
data_file = storage_path / 'data' / f"{storage_id}.enc"
|
|
|
|
if not data_file.exists():
|
|
raise NotFoundError("File not available")
|
|
|
|
# Sanitize filename for Content-Disposition header
|
|
from urllib.parse import quote
|
|
safe_filename = filename.replace('"', "'").encode('ascii', 'replace').decode('ascii')
|
|
encoded_filename = quote(filename, safe='')
|
|
|
|
# Stream file chunks without loading all into memory
|
|
return StreamingResponse(
|
|
crypto.decrypt_file_generator(data_file),
|
|
media_type=mime_type,
|
|
headers={
|
|
"Content-Disposition": f"attachment; filename=\"{safe_filename}\"; filename*=UTF-8''{encoded_filename}"
|
|
}
|
|
)
|
|
|
|
|
|
@router.post("/export/batch")
|
|
@limiter.limit("10/minute")
|
|
@handle_exceptions
|
|
async def export_batch(
|
|
request: Request,
|
|
body: ExportBatchRequest,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Export multiple files as a ZIP."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
config = _get_config(db)
|
|
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
|
|
# Create ZIP in memory
|
|
zip_buffer = BytesIO()
|
|
|
|
with ZipFile(zip_buffer, 'w') as zf:
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
for media_id in body.media_ids:
|
|
cursor.execute('''
|
|
SELECT m.storage_id, m.encrypted_filename, m.encrypted_media_date,
|
|
p.encrypted_name as person_name
|
|
FROM private_media m
|
|
LEFT JOIN private_media_persons p ON m.person_id = p.id
|
|
WHERE m.id = ?
|
|
''', (media_id,))
|
|
row = cursor.fetchone()
|
|
|
|
if not row:
|
|
continue
|
|
|
|
storage_id = row['storage_id']
|
|
filename = crypto.decrypt_field(row['encrypted_filename'])
|
|
media_date = crypto.decrypt_field(row['encrypted_media_date'])
|
|
person_name = crypto.decrypt_field(row['person_name']) if row['person_name'] else 'Unknown'
|
|
|
|
data_file = storage_path / 'data' / f"{storage_id}.enc"
|
|
if not data_file.exists():
|
|
continue
|
|
|
|
decrypted = crypto.decrypt_file_streaming(data_file)
|
|
if not decrypted:
|
|
continue
|
|
|
|
# Build path in ZIP
|
|
path_parts = []
|
|
if body.organize_by_person:
|
|
path_parts.append(person_name.replace('/', '_'))
|
|
if body.organize_by_date:
|
|
path_parts.append(media_date)
|
|
path_parts.append(filename)
|
|
|
|
zip_path = '/'.join(path_parts)
|
|
zf.writestr(zip_path, decrypted)
|
|
|
|
zip_buffer.seek(0)
|
|
|
|
return Response(
|
|
content=zip_buffer.getvalue(),
|
|
media_type="application/zip",
|
|
headers={
|
|
"Content-Disposition": f'attachment; filename="private_gallery_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.zip"'
|
|
}
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# ALBUMS (Auto-generated from persons)
|
|
# ============================================================================
|
|
|
|
@router.get("/albums")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def get_albums(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get all person albums with cover image and count."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Get persons with media counts
|
|
cursor.execute('''
|
|
SELECT p.id, p.encrypted_name, p.relationship_id,
|
|
r.encrypted_name as rel_encrypted_name, r.color as rel_color,
|
|
COUNT(m.id) as item_count,
|
|
(SELECT id FROM private_media WHERE person_id = p.id ORDER BY created_at DESC LIMIT 1) as latest_media_id
|
|
FROM private_media_persons p
|
|
JOIN private_media_relationships r ON p.relationship_id = r.id
|
|
LEFT JOIN private_media m ON m.person_id = p.id
|
|
GROUP BY p.id
|
|
ORDER BY p.encrypted_name
|
|
''')
|
|
rows = cursor.fetchall()
|
|
|
|
albums = []
|
|
for row in rows:
|
|
album = {
|
|
'person_id': row['id'],
|
|
'person_name': crypto.decrypt_field(row['encrypted_name']),
|
|
'relationship': {
|
|
'id': row['relationship_id'],
|
|
'name': crypto.decrypt_field(row['rel_encrypted_name']),
|
|
'color': row['rel_color']
|
|
},
|
|
'item_count': row['item_count'],
|
|
'cover_thumbnail_url': f"/api/private-gallery/thumbnail/{row['latest_media_id']}" if row['latest_media_id'] else None
|
|
}
|
|
albums.append(album)
|
|
|
|
# Sort by decrypted name
|
|
albums.sort(key=lambda a: a['person_name'].lower())
|
|
|
|
return {"albums": albums}
|
|
|
|
|
|
# ============================================================================
|
|
# STATS ENDPOINT
|
|
# ============================================================================
|
|
|
|
@router.post("/reparse-dates")
|
|
@limiter.limit("5/minute")
|
|
@handle_exceptions
|
|
async def reparse_dates(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
):
|
|
"""Re-extract dates from original filenames for all media items.
|
|
Requires gallery to be unlocked (encryption key in memory) but does not require gallery token."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
if not crypto.is_initialized():
|
|
raise AuthError("Gallery must be unlocked to reparse dates")
|
|
|
|
updated = 0
|
|
debug_info = []
|
|
with db.get_connection(for_write=True) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, encrypted_filename, encrypted_media_date FROM private_media')
|
|
rows = cursor.fetchall()
|
|
|
|
for row in rows:
|
|
filename = crypto.decrypt_field(row['encrypted_filename'])
|
|
old_date = crypto.decrypt_field(row['encrypted_media_date'])
|
|
|
|
# Try to extract a better date from filename
|
|
new_date = _extract_date_from_filename(filename)
|
|
|
|
# Log first 5 for debugging
|
|
if len(debug_info) < 5:
|
|
debug_info.append({
|
|
"id": row['id'],
|
|
"filename": filename,
|
|
"old_date": old_date,
|
|
"extracted_date": new_date,
|
|
"will_update": bool(new_date and new_date != old_date)
|
|
})
|
|
|
|
if new_date and new_date != old_date:
|
|
cursor.execute(
|
|
'UPDATE private_media SET encrypted_media_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
|
(crypto.encrypt_field(new_date), row['id'])
|
|
)
|
|
updated += 1
|
|
|
|
conn.commit()
|
|
|
|
return {
|
|
"message": f"Updated {updated} media items with re-parsed dates",
|
|
"updated": updated,
|
|
"total": len(rows),
|
|
"debug_samples": debug_info
|
|
}
|
|
|
|
|
|
@router.get("/stats")
|
|
@limiter.limit("60/minute")
|
|
@handle_exceptions
|
|
async def get_stats(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get gallery statistics."""
|
|
db = _get_db()
|
|
config = _get_config(db)
|
|
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Total items
|
|
cursor.execute('SELECT COUNT(*) as total FROM private_media')
|
|
total_items = cursor.fetchone()['total']
|
|
|
|
# Total size
|
|
cursor.execute('SELECT COALESCE(SUM(file_size), 0) as total FROM private_media')
|
|
total_size = cursor.fetchone()['total']
|
|
|
|
# By type
|
|
cursor.execute('''
|
|
SELECT file_type, COUNT(*) as count
|
|
FROM private_media
|
|
GROUP BY file_type
|
|
''')
|
|
by_type = {row['file_type']: row['count'] for row in cursor.fetchall()}
|
|
|
|
# By person
|
|
cursor.execute('''
|
|
SELECT p.id, COUNT(m.id) as count
|
|
FROM private_media_persons p
|
|
LEFT JOIN private_media m ON m.person_id = p.id
|
|
GROUP BY p.id
|
|
''')
|
|
by_person = {row['id']: row['count'] for row in cursor.fetchall()}
|
|
|
|
# Person count
|
|
cursor.execute('SELECT COUNT(*) as total FROM private_media_persons')
|
|
total_persons = cursor.fetchone()['total']
|
|
|
|
# Recent items (last 7 days)
|
|
cursor.execute('''
|
|
SELECT COUNT(*) as count FROM private_media
|
|
WHERE created_at >= datetime('now', '-7 days')
|
|
''')
|
|
recent_7d = cursor.fetchone()['count']
|
|
|
|
# Calculate storage usage
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
try:
|
|
data_size = sum(f.stat().st_size for f in (storage_path / 'data').glob('*.enc'))
|
|
thumb_size = sum(f.stat().st_size for f in (storage_path / 'thumbs').glob('*.enc'))
|
|
storage_used = data_size + thumb_size
|
|
except Exception:
|
|
storage_used = 0
|
|
|
|
return {
|
|
"total_items": total_items,
|
|
"total_size": total_size,
|
|
"total_persons": total_persons,
|
|
"by_type": by_type,
|
|
"by_person_count": by_person,
|
|
"recent_7d": recent_7d,
|
|
"storage_used": storage_used
|
|
}
|
|
|
|
|
|
@router.post("/migrate-chunked")
|
|
@limiter.limit("2/minute")
|
|
@handle_exceptions
|
|
async def migrate_to_chunked(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Re-encrypt single-shot files >50MB to chunked format for streaming."""
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
config = _get_config(db)
|
|
storage_path = Path(config.get('storage_path', '/opt/immich/private'))
|
|
data_path = storage_path / 'data'
|
|
|
|
min_size = 50 * 1024 * 1024 # 50MB threshold
|
|
|
|
# Find files that need migration
|
|
with db.get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id, storage_id, file_size FROM private_media WHERE file_size > ? ORDER BY file_size ASC', (min_size,))
|
|
rows = cursor.fetchall()
|
|
|
|
to_migrate = []
|
|
for row in rows:
|
|
enc_file = data_path / f"{row['storage_id']}.enc"
|
|
if enc_file.exists() and not crypto._is_chunked_format(enc_file):
|
|
to_migrate.append(row)
|
|
|
|
if not to_migrate:
|
|
return {"status": "done", "message": "No files need migration", "migrated": 0}
|
|
|
|
# Run migration in background thread
|
|
job_id = f"pg_migrate_{secrets.token_hex(6)}"
|
|
_update_pg_job(job_id, {
|
|
'status': 'running',
|
|
'total_files': len(to_migrate),
|
|
'processed_files': 0,
|
|
'migrated': 0,
|
|
'failed': 0,
|
|
'current_file': ''
|
|
})
|
|
|
|
def _run_migration():
|
|
migrated = 0
|
|
failed = 0
|
|
for idx, row in enumerate(to_migrate):
|
|
enc_file = data_path / f"{row['storage_id']}.enc"
|
|
_update_pg_job(job_id, {
|
|
'current_file': f"ID {row['id']} ({row['file_size'] / 1e6:.0f}MB)",
|
|
'processed_files': idx
|
|
})
|
|
try:
|
|
if crypto.re_encrypt_to_chunked(enc_file):
|
|
migrated += 1
|
|
logger.info(f"Migrated ID {row['id']} ({row['file_size']/1e6:.0f}MB) to chunked format")
|
|
else:
|
|
failed += 1
|
|
except Exception as e:
|
|
logger.error(f"Migration failed for ID {row['id']}: {e}")
|
|
failed += 1
|
|
|
|
_update_pg_job(job_id, {
|
|
'processed_files': idx + 1,
|
|
'migrated': migrated,
|
|
'failed': failed
|
|
})
|
|
|
|
_invalidate_posts_cache()
|
|
_update_pg_job(job_id, {
|
|
'status': 'completed',
|
|
'processed_files': len(to_migrate),
|
|
'migrated': migrated,
|
|
'failed': failed
|
|
})
|
|
|
|
import threading
|
|
t = threading.Thread(target=_run_migration, daemon=True)
|
|
t.start()
|
|
|
|
return {
|
|
"status": "started",
|
|
"job_id": job_id,
|
|
"total_files": len(to_migrate),
|
|
"total_size_mb": sum(r['file_size'] for r in to_migrate) / 1e6
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# REDDIT COMMUNITY MONITOR ENDPOINTS
|
|
# ============================================================================
|
|
|
|
def _get_reddit_monitor(with_activity_manager: bool = False):
|
|
"""Get or create the Reddit community monitor instance."""
|
|
db = _get_db()
|
|
db_path = str(Path(db.db_path) if hasattr(db, 'db_path') else Path(__file__).parent.parent.parent / 'database' / 'media_downloader.db')
|
|
from modules.reddit_community_monitor import RedditCommunityMonitor
|
|
activity_manager = None
|
|
if with_activity_manager:
|
|
from modules.activity_status import get_activity_manager
|
|
app_state = get_app_state()
|
|
activity_manager = get_activity_manager(app_state.db if app_state else None)
|
|
return RedditCommunityMonitor(db_path, activity_manager)
|
|
|
|
|
|
@router.get("/reddit/settings")
|
|
@handle_exceptions
|
|
async def get_reddit_settings(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get Reddit monitor settings."""
|
|
monitor = _get_reddit_monitor()
|
|
settings = monitor.get_settings()
|
|
# Check if encrypted cookies exist (don't expose them)
|
|
crypto = _get_crypto()
|
|
settings['has_cookies'] = monitor.has_cookies(crypto)
|
|
return {"settings": settings}
|
|
|
|
|
|
@router.put("/reddit/settings")
|
|
@handle_exceptions
|
|
async def update_reddit_settings(
|
|
request: Request,
|
|
data: RedditMonitorSettingsUpdate,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Update Reddit monitor settings."""
|
|
monitor = _get_reddit_monitor()
|
|
crypto = _get_crypto()
|
|
|
|
updates = {k: v for k, v in data.model_dump().items() if v is not None}
|
|
|
|
# Handle key file export/deletion when enabling/disabling
|
|
if 'enabled' in updates:
|
|
from modules.private_gallery_crypto import export_key_to_file, delete_key_file
|
|
from modules.reddit_community_monitor import REDDIT_MONITOR_KEY_FILE
|
|
|
|
if updates['enabled']:
|
|
if not crypto.is_initialized():
|
|
raise AuthError("Gallery must be unlocked to enable Reddit monitor")
|
|
if not export_key_to_file(REDDIT_MONITOR_KEY_FILE):
|
|
raise ValidationError("Failed to export encryption key for background monitoring")
|
|
else:
|
|
delete_key_file(REDDIT_MONITOR_KEY_FILE)
|
|
|
|
if monitor.update_settings(**updates):
|
|
return {"message": "Reddit monitor settings updated"}
|
|
raise ValidationError("Failed to update settings")
|
|
|
|
|
|
@router.put("/reddit/cookies")
|
|
@handle_exceptions
|
|
async def upload_reddit_cookies(
|
|
request: Request,
|
|
data: RedditCookiesUpload,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Upload JSON cookies for Reddit authentication. Stored encrypted."""
|
|
crypto = _get_crypto()
|
|
if not crypto.is_initialized():
|
|
raise AuthError("Gallery must be unlocked to upload cookies")
|
|
|
|
# Validate JSON
|
|
try:
|
|
parsed = json.loads(data.cookies_json)
|
|
if not isinstance(parsed, list):
|
|
raise ValidationError("Cookies must be a JSON array")
|
|
except json.JSONDecodeError:
|
|
raise ValidationError("Invalid JSON")
|
|
|
|
monitor = _get_reddit_monitor()
|
|
if monitor.save_cookies(crypto, data.cookies_json):
|
|
return {"message": "Cookies saved"}
|
|
raise ValidationError("Failed to save cookies")
|
|
|
|
|
|
@router.delete("/reddit/cookies")
|
|
@handle_exceptions
|
|
async def delete_reddit_cookies(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Delete stored Reddit cookies."""
|
|
monitor = _get_reddit_monitor()
|
|
monitor.delete_cookies()
|
|
return {"message": "Cookies deleted"}
|
|
|
|
|
|
@router.get("/reddit/communities")
|
|
@handle_exceptions
|
|
async def get_reddit_communities(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get all Reddit community mappings with decrypted person names."""
|
|
monitor = _get_reddit_monitor()
|
|
crypto = _get_crypto()
|
|
communities = monitor.get_all_communities()
|
|
|
|
# Decrypt person names
|
|
result = []
|
|
for c in communities:
|
|
item = {
|
|
'id': c['id'],
|
|
'subreddit_name': c['subreddit_name'],
|
|
'person_id': c['person_id'],
|
|
'enabled': bool(c['enabled']),
|
|
'last_checked': c['last_checked'],
|
|
'total_media_found': c['total_media_found'],
|
|
'created_at': c['created_at'],
|
|
'updated_at': c['updated_at'],
|
|
}
|
|
if c.get('person_encrypted_name'):
|
|
try:
|
|
item['person_name'] = crypto.decrypt_field(c['person_encrypted_name'])
|
|
except Exception:
|
|
item['person_name'] = '[Decryption Error]'
|
|
else:
|
|
item['person_name'] = 'Unknown'
|
|
|
|
if c.get('relationship_encrypted_name'):
|
|
try:
|
|
item['relationship_name'] = crypto.decrypt_field(c['relationship_encrypted_name'])
|
|
except Exception:
|
|
item['relationship_name'] = ''
|
|
else:
|
|
item['relationship_name'] = ''
|
|
|
|
item['relationship_color'] = c.get('relationship_color', '#6b7280')
|
|
result.append(item)
|
|
|
|
return {"communities": result}
|
|
|
|
|
|
@router.post("/reddit/communities")
|
|
@handle_exceptions
|
|
async def add_reddit_community(
|
|
request: Request,
|
|
data: RedditCommunityCreate,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Add a new Reddit community mapping."""
|
|
monitor = _get_reddit_monitor()
|
|
try:
|
|
community_id = monitor.add_community(data.subreddit_name, data.person_id)
|
|
return {"id": community_id, "message": "Community added"}
|
|
except Exception as e:
|
|
if 'UNIQUE constraint' in str(e):
|
|
raise ValidationError("This subreddit is already mapped to this person")
|
|
raise
|
|
|
|
|
|
@router.put("/reddit/communities/{community_id}")
|
|
@handle_exceptions
|
|
async def update_reddit_community(
|
|
request: Request,
|
|
community_id: int,
|
|
data: RedditCommunityUpdate,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Update a Reddit community mapping."""
|
|
monitor = _get_reddit_monitor()
|
|
updates = {k: v for k, v in data.model_dump().items() if v is not None}
|
|
if monitor.update_community(community_id, **updates):
|
|
return {"message": "Community updated"}
|
|
raise NotFoundError("Community not found")
|
|
|
|
|
|
@router.delete("/reddit/communities/{community_id}")
|
|
@handle_exceptions
|
|
async def delete_reddit_community(
|
|
request: Request,
|
|
community_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Delete a Reddit community mapping."""
|
|
monitor = _get_reddit_monitor()
|
|
if monitor.delete_community(community_id):
|
|
return {"message": "Community deleted"}
|
|
raise NotFoundError("Community not found")
|
|
|
|
|
|
@router.post("/reddit/check-now")
|
|
@handle_exceptions
|
|
async def trigger_reddit_check(
|
|
request: Request,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Trigger an immediate Reddit community check in the background."""
|
|
monitor = _get_reddit_monitor(with_activity_manager=True)
|
|
|
|
# Re-export key file to ensure it's current
|
|
crypto = _get_crypto()
|
|
if crypto.is_initialized():
|
|
from modules.private_gallery_crypto import export_key_to_file
|
|
from modules.reddit_community_monitor import REDDIT_MONITOR_KEY_FILE
|
|
export_key_to_file(REDDIT_MONITOR_KEY_FILE)
|
|
|
|
def run_check():
|
|
try:
|
|
import asyncio
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
try:
|
|
count = loop.run_until_complete(monitor.check_all_now())
|
|
logger.info(f"Reddit manual check complete: {count} new media", module="PrivateGallery")
|
|
if count > 0:
|
|
_invalidate_posts_cache()
|
|
finally:
|
|
loop.close()
|
|
except Exception as e:
|
|
logger.error(f"Reddit manual check failed: {e}", module="PrivateGallery")
|
|
|
|
import threading
|
|
t = threading.Thread(target=run_check, daemon=True)
|
|
t.start()
|
|
|
|
return {"message": "Reddit check started in background"}
|
|
|
|
|
|
@router.post("/reddit/communities/{community_id}/download-all")
|
|
@handle_exceptions
|
|
async def download_full_reddit_community(
|
|
request: Request,
|
|
community_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Download all available media from a Reddit community."""
|
|
monitor = _get_reddit_monitor(with_activity_manager=True)
|
|
|
|
community = monitor.get_community(community_id)
|
|
if not community:
|
|
raise NotFoundError("Community not found")
|
|
|
|
# Re-export key file
|
|
crypto = _get_crypto()
|
|
if crypto.is_initialized():
|
|
from modules.private_gallery_crypto import export_key_to_file
|
|
from modules.reddit_community_monitor import REDDIT_MONITOR_KEY_FILE
|
|
export_key_to_file(REDDIT_MONITOR_KEY_FILE)
|
|
|
|
def run_download():
|
|
try:
|
|
import asyncio
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
try:
|
|
count = loop.run_until_complete(monitor.download_full_community(community_id))
|
|
logger.info(f"Reddit full download complete: {count} media from r/{community['subreddit_name']}", module="PrivateGallery")
|
|
if count > 0:
|
|
_invalidate_posts_cache()
|
|
finally:
|
|
loop.close()
|
|
except Exception as e:
|
|
logger.error(f"Reddit full download failed: {e}", module="PrivateGallery")
|
|
|
|
import threading
|
|
t = threading.Thread(target=run_download, daemon=True)
|
|
t.start()
|
|
|
|
return {"message": f"Full download started for r/{community['subreddit_name']}"}
|
|
|
|
|
|
@router.post("/reddit/communities/{community_id}/check")
|
|
@handle_exceptions
|
|
async def check_single_reddit_community(
|
|
request: Request,
|
|
community_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Check a single Reddit community for new posts."""
|
|
monitor = _get_reddit_monitor(with_activity_manager=True)
|
|
|
|
community = monitor.get_community(community_id)
|
|
if not community:
|
|
raise NotFoundError("Community not found")
|
|
|
|
# Re-export key file
|
|
crypto = _get_crypto()
|
|
if crypto.is_initialized():
|
|
from modules.private_gallery_crypto import export_key_to_file
|
|
from modules.reddit_community_monitor import REDDIT_MONITOR_KEY_FILE
|
|
export_key_to_file(REDDIT_MONITOR_KEY_FILE)
|
|
|
|
def run_check():
|
|
try:
|
|
import asyncio
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
try:
|
|
count = loop.run_until_complete(monitor.check_single_community(community_id))
|
|
logger.info(f"Reddit single community check complete: {count} new media from r/{community['subreddit_name']}", module="PrivateGallery")
|
|
if count > 0:
|
|
_invalidate_posts_cache()
|
|
finally:
|
|
loop.close()
|
|
except Exception as e:
|
|
logger.error(f"Reddit single community check failed: {e}", module="PrivateGallery")
|
|
|
|
import threading
|
|
t = threading.Thread(target=run_check, daemon=True)
|
|
t.start()
|
|
|
|
return {"message": f"Check started for r/{community['subreddit_name']}"}
|
|
|
|
|
|
@router.post("/reddit/communities/check-by-person/{person_id}")
|
|
@handle_exceptions
|
|
async def check_reddit_communities_by_person(
|
|
request: Request,
|
|
person_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Check all Reddit communities for a specific person."""
|
|
monitor = _get_reddit_monitor(with_activity_manager=True)
|
|
|
|
# Re-export key file
|
|
crypto = _get_crypto()
|
|
if crypto.is_initialized():
|
|
from modules.private_gallery_crypto import export_key_to_file
|
|
from modules.reddit_community_monitor import REDDIT_MONITOR_KEY_FILE
|
|
export_key_to_file(REDDIT_MONITOR_KEY_FILE)
|
|
|
|
def run_check():
|
|
try:
|
|
import asyncio
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
try:
|
|
count = loop.run_until_complete(monitor.check_communities_by_person(person_id))
|
|
logger.info(f"Reddit person check complete: {count} new media for person {person_id}", module="PrivateGallery")
|
|
if count > 0:
|
|
_invalidate_posts_cache()
|
|
finally:
|
|
loop.close()
|
|
except Exception as e:
|
|
logger.error(f"Reddit person check failed: {e}", module="PrivateGallery")
|
|
|
|
import threading
|
|
t = threading.Thread(target=run_check, daemon=True)
|
|
t.start()
|
|
|
|
return {"message": f"Check started for all communities of person {person_id}"}
|
|
|
|
|
|
@router.get("/reddit/history/{community_id}")
|
|
@handle_exceptions
|
|
async def get_reddit_history(
|
|
request: Request,
|
|
community_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get download history for a Reddit community."""
|
|
monitor = _get_reddit_monitor()
|
|
history = monitor.get_history(community_id)
|
|
return {"history": history}
|
|
|
|
|
|
# ============================================================================
|
|
# SCRAPER ACCOUNT MAPPINGS (Instagram, TikTok, Snapchat)
|
|
# ============================================================================
|
|
|
|
VALID_SCRAPER_PLATFORMS = ('instagram', 'tiktok', 'snapchat')
|
|
|
|
|
|
def _validate_scraper_platform(platform: str):
|
|
if platform not in VALID_SCRAPER_PLATFORMS:
|
|
raise ValidationError(f"Invalid platform: {platform}. Must be one of: {', '.join(VALID_SCRAPER_PLATFORMS)}")
|
|
|
|
|
|
@router.get("/scraper-accounts/{platform}/available")
|
|
@handle_exceptions
|
|
async def get_available_scraper_accounts(
|
|
request: Request,
|
|
platform: str,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Get de-duplicated list of accounts from all scrapers for a platform."""
|
|
_validate_scraper_platform(platform)
|
|
db = _get_db()
|
|
|
|
# Load config
|
|
app_state = get_app_state()
|
|
config = {}
|
|
if hasattr(app_state, 'settings_manager') and app_state.settings_manager:
|
|
config = app_state.settings_manager.get_all()
|
|
elif hasattr(app_state, 'config'):
|
|
config = app_state.config or {}
|
|
|
|
from modules.scraper_gallery_bridge import get_available_accounts
|
|
accounts = get_available_accounts(platform, config, db)
|
|
return {"accounts": accounts}
|
|
|
|
|
|
@router.get("/scraper-accounts/{platform}")
|
|
@handle_exceptions
|
|
async def get_scraper_accounts(
|
|
request: Request,
|
|
platform: str,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""List all scraper account mappings for a platform."""
|
|
_validate_scraper_platform(platform)
|
|
db = _get_db()
|
|
crypto = _get_crypto()
|
|
|
|
import sqlite3
|
|
conn = sqlite3.connect(db.db_path, timeout=10)
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT sa.*, p.encrypted_name as person_encrypted_name,
|
|
r.encrypted_name as relationship_encrypted_name,
|
|
r.color as relationship_color
|
|
FROM private_media_scraper_accounts sa
|
|
JOIN private_media_persons p ON sa.person_id = p.id
|
|
LEFT JOIN private_media_relationships r ON p.relationship_id = r.id
|
|
WHERE sa.platform = ?
|
|
ORDER BY sa.created_at DESC
|
|
''', (platform,))
|
|
rows = cursor.fetchall()
|
|
finally:
|
|
conn.close()
|
|
|
|
accounts = []
|
|
for row in rows:
|
|
item = {
|
|
'id': row['id'],
|
|
'platform': row['platform'],
|
|
'username': row['username'],
|
|
'person_id': row['person_id'],
|
|
'enabled': bool(row['enabled']),
|
|
'last_imported_at': row['last_imported_at'],
|
|
'total_media_imported': row['total_media_imported'],
|
|
'created_at': row['created_at'],
|
|
'updated_at': row['updated_at'],
|
|
}
|
|
try:
|
|
item['person_name'] = crypto.decrypt_field(row['person_encrypted_name'])
|
|
except Exception:
|
|
item['person_name'] = '[Decryption Error]'
|
|
try:
|
|
item['relationship_name'] = crypto.decrypt_field(row['relationship_encrypted_name']) if row['relationship_encrypted_name'] else None
|
|
except Exception:
|
|
item['relationship_name'] = None
|
|
item['relationship_color'] = row['relationship_color']
|
|
accounts.append(item)
|
|
|
|
return {"accounts": accounts}
|
|
|
|
|
|
@router.post("/scraper-accounts/{platform}")
|
|
@handle_exceptions
|
|
async def add_scraper_account(
|
|
request: Request,
|
|
platform: str,
|
|
data: ScraperAccountCreate,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Add a new scraper account → person mapping."""
|
|
_validate_scraper_platform(platform)
|
|
db = _get_db()
|
|
|
|
username = data.username.strip().lower().lstrip('@')
|
|
if not username:
|
|
raise ValidationError("Username is required")
|
|
|
|
import sqlite3
|
|
conn = sqlite3.connect(db.db_path, timeout=10)
|
|
try:
|
|
cursor = conn.cursor()
|
|
now = datetime.now().isoformat()
|
|
try:
|
|
cursor.execute('''
|
|
INSERT INTO private_media_scraper_accounts (platform, username, person_id, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
''', (platform, username, data.person_id, now, now))
|
|
conn.commit()
|
|
|
|
# Export key file so scheduler can import even when gallery is locked
|
|
crypto = _get_crypto()
|
|
if crypto.is_initialized():
|
|
from modules.private_gallery_crypto import export_key_to_file
|
|
from modules.scraper_gallery_bridge import SCRAPER_BRIDGE_KEY_FILE
|
|
export_key_to_file(SCRAPER_BRIDGE_KEY_FILE)
|
|
|
|
return {"id": cursor.lastrowid, "message": "Account mapping added"}
|
|
except sqlite3.IntegrityError as e:
|
|
if 'UNIQUE constraint' in str(e):
|
|
raise ValidationError("This account is already mapped to this person")
|
|
if 'FOREIGN KEY constraint' in str(e):
|
|
raise ValidationError("Person not found")
|
|
raise
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.put("/scraper-accounts/{platform}/{account_id}")
|
|
@handle_exceptions
|
|
async def update_scraper_account(
|
|
request: Request,
|
|
platform: str,
|
|
account_id: int,
|
|
data: ScraperAccountUpdate,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Update a scraper account mapping."""
|
|
_validate_scraper_platform(platform)
|
|
db = _get_db()
|
|
|
|
import sqlite3
|
|
conn = sqlite3.connect(db.db_path, timeout=10)
|
|
try:
|
|
cursor = conn.cursor()
|
|
updates = []
|
|
params = []
|
|
|
|
if data.person_id is not None:
|
|
updates.append("person_id = ?")
|
|
params.append(data.person_id)
|
|
if data.enabled is not None:
|
|
updates.append("enabled = ?")
|
|
params.append(1 if data.enabled else 0)
|
|
|
|
if not updates:
|
|
raise ValidationError("No fields to update")
|
|
|
|
updates.append("updated_at = ?")
|
|
params.append(datetime.now().isoformat())
|
|
params.append(account_id)
|
|
params.append(platform)
|
|
|
|
cursor.execute(
|
|
f'UPDATE private_media_scraper_accounts SET {", ".join(updates)} WHERE id = ? AND platform = ?',
|
|
params
|
|
)
|
|
conn.commit()
|
|
|
|
if cursor.rowcount == 0:
|
|
raise NotFoundError("Account mapping not found")
|
|
|
|
return {"message": "Account mapping updated"}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.delete("/scraper-accounts/{platform}/{account_id}")
|
|
@handle_exceptions
|
|
async def delete_scraper_account(
|
|
request: Request,
|
|
platform: str,
|
|
account_id: int,
|
|
current_user: Dict = Depends(get_current_user),
|
|
session: Dict = Depends(_verify_gallery_token)
|
|
):
|
|
"""Delete a scraper account mapping."""
|
|
_validate_scraper_platform(platform)
|
|
db = _get_db()
|
|
|
|
import sqlite3
|
|
conn = sqlite3.connect(db.db_path, timeout=10)
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
'DELETE FROM private_media_scraper_accounts WHERE id = ? AND platform = ?',
|
|
(account_id, platform)
|
|
)
|
|
conn.commit()
|
|
if cursor.rowcount == 0:
|
|
raise NotFoundError("Account mapping not found")
|
|
return {"message": "Account mapping deleted"}
|
|
finally:
|
|
conn.close()
|
|
|