feat: structure 2

This commit is contained in:
2026-04-07 00:15:00 +02:00
parent 46a0d7aba8
commit 6d1b227fb9
15 changed files with 7433 additions and 94 deletions

View File

@@ -3,7 +3,7 @@ Database configuration and session management
"""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import declarative_base
from sqlalchemy import event
from sqlalchemy import event, text
import logging
import os
from pathlib import Path
@@ -17,16 +17,23 @@ db_path = Path(settings.database_url.replace("sqlite+aiosqlite:///", ""))
db_path.parent.mkdir(parents=True, exist_ok=True)
# Create async engine
engine = create_async_engine(
settings.database_url,
echo=False, # Set to True for SQL debugging
pool_size=settings.performance.db_pool_size,
pool_recycle=settings.performance.db_pool_recycle,
connect_args={
"check_same_thread": False, # SQLite specific
"timeout": 30
} if "sqlite" in settings.database_url else {}
)
# SQLite doesn't support pool configuration
if "sqlite" in settings.database_url:
engine = create_async_engine(
settings.database_url,
echo=False, # Set to True for SQL debugging
connect_args={
"check_same_thread": False, # SQLite specific
"timeout": 30
}
)
else:
engine = create_async_engine(
settings.database_url,
echo=False, # Set to True for SQL debugging
pool_size=settings.performance.db_pool_size,
pool_recycle=settings.performance.db_pool_recycle
)
# Create async session factory
AsyncSessionLocal = async_sessionmaker(
@@ -57,10 +64,10 @@ async def init_db():
# Enable WAL mode for SQLite (better concurrency)
if "sqlite" in settings.database_url:
await conn.execute("PRAGMA journal_mode=WAL")
await conn.execute("PRAGMA synchronous=NORMAL")
await conn.execute("PRAGMA cache_size=10000")
await conn.execute("PRAGMA temp_store=MEMORY")
await conn.execute(text("PRAGMA journal_mode=WAL"))
await conn.execute(text("PRAGMA synchronous=NORMAL"))
await conn.execute(text("PRAGMA cache_size=10000"))
await conn.execute(text("PRAGMA temp_store=MEMORY"))
logger.info("Database initialized successfully")
@@ -69,7 +76,7 @@ async def create_fts_table():
if "sqlite" in settings.database_url:
async with engine.begin() as conn:
# Create FTS5 virtual table for full-text search
await conn.execute("""
await conn.execute(text("""
CREATE VIRTUAL TABLE IF NOT EXISTS photos_fts USING fts5(
photo_id UNINDEXED,
filename,
@@ -78,5 +85,5 @@ async def create_fts_table():
exif_text,
tokenize='unicode61'
)
""")
"""))
logger.info("FTS5 table created successfully")

View File

@@ -11,13 +11,26 @@ import json
from celery import shared_task
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import pyvips
import rawpy
import imageio
from PIL import Image
import imageio
from pillow_heif import register_heif_opener
import ffmpeg
# Try to import optional libraries
try:
import pyvips
PYVIPS_AVAILABLE = True
except ImportError:
PYVIPS_AVAILABLE = False
print("pyvips not available, using Pillow for image processing")
try:
import rawpy
RAWPY_AVAILABLE = True
except ImportError:
RAWPY_AVAILABLE = False
print("rawpy not available, using exiftool for RAW preview extraction")
from app.database import AsyncSessionLocal
from app.models import Photo
from app.config import settings
@@ -40,24 +53,28 @@ def get_thumb_path(photo_id: str, size: str) -> str:
os.makedirs(thumb_dir, exist_ok=True)
return f"{thumb_dir}/{size}.{settings.thumbnails.format}"
def process_standard_image(filepath: str) -> pyvips.Image:
def process_standard_image(filepath: str) -> Image.Image:
"""Process standard image formats (JPEG, PNG, etc.)"""
return pyvips.Image.new_from_file(filepath, access='sequential')
return Image.open(filepath)
def process_raw_image(filepath: str) -> pyvips.Image:
def process_raw_image(filepath: str) -> Image.Image:
"""Process RAW image formats"""
try:
with rawpy.imread(filepath) as raw:
# Use half_size for faster processing
rgb = raw.postprocess(use_camera_wb=True, half_size=True)
# Convert numpy array to pyvips image
return pyvips.Image.new_from_array(rgb)
except Exception as e:
logger.error(f"Error processing RAW file {filepath}: {e}")
# Try to extract embedded JPEG preview
if RAWPY_AVAILABLE:
try:
with rawpy.imread(filepath) as raw:
# Use half_size for faster processing
rgb = raw.postprocess(use_camera_wb=True, half_size=True)
# Convert numpy array to PIL Image
return Image.fromarray(rgb, 'RGB')
except Exception as e:
logger.error(f"Error processing RAW file {filepath}: {e}")
# Try to extract embedded JPEG preview
return extract_raw_preview(filepath)
else:
# Use exiftool to extract embedded preview
return extract_raw_preview(filepath)
def extract_raw_preview(filepath: str) -> Optional[pyvips.Image]:
def extract_raw_preview(filepath: str) -> Optional[Image.Image]:
"""Extract embedded JPEG preview from RAW file"""
try:
# Use exiftool to extract preview
@@ -71,13 +88,13 @@ def extract_raw_preview(filepath: str) -> Optional[pyvips.Image]:
if result.returncode == 0 and result.stdout:
tmp.write(result.stdout)
tmp.flush()
return pyvips.Image.new_from_file(tmp.name, access='sequential')
return Image.open(tmp.name)
except Exception as e:
logger.error(f"Error extracting RAW preview from {filepath}: {e}")
return None
def process_heic_image(filepath: str) -> pyvips.Image:
def process_heic_image(filepath: str) -> Image.Image:
"""Process HEIC/HEIF image formats"""
try:
# Use pillow-heif to open the image
@@ -85,16 +102,12 @@ def process_heic_image(filepath: str) -> pyvips.Image:
# Convert to RGB if needed
if img.mode != 'RGB':
img = img.convert('RGB')
# Save to temp file and load with pyvips
import tempfile
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
img.save(tmp.name, 'JPEG')
return pyvips.Image.new_from_file(tmp.name, access='sequential')
return img
except Exception as e:
logger.error(f"Error processing HEIC file {filepath}: {e}")
raise
def process_video_thumbnail(filepath: str) -> pyvips.Image:
def process_video_thumbnail(filepath: str) -> Image.Image:
"""Extract thumbnail from video file"""
try:
# Get video duration
@@ -111,57 +124,50 @@ def process_video_thumbnail(filepath: str) -> pyvips.Image:
stream = ffmpeg.output(stream, tmp.name, vframes=1, format='image2', vcodec='mjpeg')
ffmpeg.run(stream, capture_stdout=True, capture_stderr=True)
return pyvips.Image.new_from_file(tmp.name, access='sequential')
return Image.open(tmp.name)
except Exception as e:
logger.error(f"Error extracting video thumbnail from {filepath}: {e}")
# Create a placeholder thumbnail
return create_placeholder_thumbnail('video')
def create_placeholder_thumbnail(media_type: str) -> pyvips.Image:
def create_placeholder_thumbnail(media_type: str) -> Image.Image:
"""Create a placeholder thumbnail for failed processing"""
# Create a simple gray placeholder
placeholder = pyvips.Image.black(640, 480)
placeholder = placeholder + [128, 128, 128] # Make it gray
return placeholder
img = Image.new('RGB', (640, 480), color=(128, 128, 128))
return img
def auto_rotate_image(image: pyvips.Image) -> pyvips.Image:
def auto_rotate_image(image: Image.Image) -> Image.Image:
"""Auto-rotate image based on EXIF orientation"""
try:
orientation = image.get('orientation')
rotation_map = {
3: 180,
6: 90,
8: 270
}
if orientation in rotation_map:
image = image.rot(rotation_map[orientation])
# Get EXIF data
exif = image._getexif()
if exif:
orientation = exif.get(274) # Orientation tag
rotation_map = {
3: 180,
6: 270, # Note: PIL uses different rotation values than vips
8: 90
}
if orientation in rotation_map:
image = image.rotate(rotation_map[orientation], expand=True)
except:
pass # No orientation data available
return image
def generate_thumbnail(image: pyvips.Image, size: int, output_path: str):
def generate_thumbnail(image: Image.Image, size: int, output_path: str):
"""Generate a thumbnail of the specified size"""
# Calculate scale to fit within size (longest edge)
width = image.width
height = image.height
if width > height:
scale = size / width
else:
scale = size / height
# Only downscale, never upscale
if scale < 1:
image = image.resize(scale)
# Maintain aspect ratio
image.thumbnail((size, size), Image.Resampling.LANCZOS)
# Save as WebP with specified quality
image.webpsave(
image.save(
output_path,
Q=settings.thumbnails.quality,
effort=4 # Balance between speed and compression
'WEBP',
quality=settings.thumbnails.quality,
method=4 # Balance between speed and compression
)
@shared_task(bind=True, name='generate_thumbnails')

View File

@@ -14,8 +14,8 @@ celery==5.3.6
flower==2.0.1
# Image processing
pyvips==2.2.2
rawpy==0.19.0
# pyvips==2.2.1 # Optional - having compatibility issues, using Pillow as fallback
# rawpy==0.19.0 # Optional - numpy compatibility issues, using Pillow as fallback
pillow==10.2.0
pillow-heif==0.15.0
imageio==2.33.1
@@ -38,8 +38,7 @@ python-dotenv==1.0.0
httpx==0.26.0
aiofiles==23.2.1
# Hashing and security
hashlib
# Security and authentication
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4