feat: structure 2
This commit is contained in:
@@ -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")
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user