diff --git a/README.md b/README.md
index 16cfb03..1b8b7ca 100644
--- a/README.md
+++ b/README.md
@@ -7,10 +7,10 @@ A self-hosted image and video gallery application designed for organizing and vi
- **Gallery View** - Browse images in a paginated grid with thumbnail previews
- **Showcase View** - Randomized masonry layout for discovery
- **Image Modal** - Full-size viewing with zoom, pan, and keyboard navigation
-- **Video Support** - Playback for video files with thumbnail generation
+- **Video Support** - Playback for video files with automatic transcoding to MP4
- **Tagging System** - Organize images with tags (artist, character, series, rating, archive, user-defined)
- **Tag Autocomplete** - Quick tag entry with search suggestions
-- **Automatic Importing** - Scans `/import` directory on schedule or manual trigger
+- **Automatic Importing** - Celery-based task queue scans `/import` directory on schedule
- **Archive Extraction** - Supports ZIP, RAR, 7z and other archive formats
- **Duplicate Detection** - Perceptual hash (pHash) comparison to skip similar images
- **Import Filters** - Skip images by dimensions or transparency percentage
@@ -24,44 +24,129 @@ A self-hosted image and video gallery application designed for organizing and vi
```yaml
services:
- imagerepo:
+ redis:
+ image: redis:7-alpine
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_USER: imagerepo
+ POSTGRES_PASSWORD: your_secure_password
+ POSTGRES_DB: imagerepo
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U imagerepo"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ web:
image: git.fabledsword.com/bvandeusen/imagerepo:latest
ports:
- "5000:5000"
- volumes:
- - ./imagerepo/db:/db # SQLite database storage
- - ./imagerepo/images:/images # Imported images and thumbnails
- - ./your-media:/import # Source directory to import from
environment:
- - THUMBS_VERBOSE=1
- - THUMBS_LOG_EVERY=50
+ - DB_USER=imagerepo
+ - DB_PASS=your_secure_password
+ - DB_HOST=postgres
+ - DB_NAME=imagerepo
+ - CELERY_BROKER_URL=redis://redis:6379/0
+ - CELERY_RESULT_BACKEND=redis://redis:6379/0
+ volumes:
+ - ./imagerepo/images:/images
+ - ./your-media:/import
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+
+ celery-worker:
+ image: git.fabledsword.com/bvandeusen/imagerepo:latest
+ command: celery -A app.celery_app:celery worker --loglevel=info -Q scan,import,thumbnail,sidecar,default --concurrency=2
+ environment:
+ - DB_USER=imagerepo
+ - DB_PASS=your_secure_password
+ - DB_HOST=postgres
+ - DB_NAME=imagerepo
+ - CELERY_BROKER_URL=redis://redis:6379/0
+ - CELERY_RESULT_BACKEND=redis://redis:6379/0
+ volumes:
+ - ./imagerepo/images:/images
+ - ./your-media:/import
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+
+ celery-beat:
+ image: git.fabledsword.com/bvandeusen/imagerepo:latest
+ command: celery -A app.celery_app:celery beat --loglevel=info
+ environment:
+ - DB_USER=imagerepo
+ - DB_PASS=your_secure_password
+ - DB_HOST=postgres
+ - DB_NAME=imagerepo
+ - CELERY_BROKER_URL=redis://redis:6379/0
+ - CELERY_RESULT_BACKEND=redis://redis:6379/0
+ - IMPORT_EVERY_SECONDS=28800
+ depends_on:
+ - redis
+ - celery-worker
+
+volumes:
+ redis_data:
+ postgres_data:
```
Then visit `http://localhost:5000` in your browser.
+## Architecture
+
+ImageRepo uses a task queue architecture for background processing:
+
+- **Web** - Flask application serving the UI and API
+- **Celery Worker** - Processes import, thumbnail, and metadata tasks
+- **Celery Beat** - Schedules periodic tasks (directory scans, recovery)
+- **PostgreSQL** - Primary database for all data
+- **Redis** - Message broker for Celery task queue
+
## Volumes
| Path | Description |
|------|-------------|
-| `/db` | Database storage (SQLite by default) |
| `/images` | Where imported images and thumbnails are stored |
| `/import` | Source directory the importer scans for new media |
## Environment Variables
-### Database Configuration
-
-By default, ImageRepo uses SQLite. Set `DB_TYPE=postgresql` to use PostgreSQL instead.
+### Database Configuration (PostgreSQL)
| Variable | Default | Description |
|----------|---------|-------------|
-| `DB_TYPE` | `sqlite` | Database type: `sqlite` or `postgresql` |
-| `DB_NAME` | `app.db` | Database name (SQLite filename or PostgreSQL database) |
+| `DB_NAME` | `imagerepo` | PostgreSQL database name |
| `DB_USER` | `imagerepo` | PostgreSQL username |
| `DB_PASS` | `postgres` | PostgreSQL password |
| `DB_HOST` | `postgres` | PostgreSQL host |
| `DB_PORT` | `5432` | PostgreSQL port |
+### Celery Configuration
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `CELERY_BROKER_URL` | `redis://redis:6379/0` | Redis broker URL |
+| `CELERY_RESULT_BACKEND` | `redis://redis:6379/0` | Redis result backend URL |
+| `CELERY_WORKER_CONCURRENCY` | `2` | Number of worker processes |
+| `IMPORT_EVERY_SECONDS` | `28800` | Directory scan interval (8 hours) |
+
### Import Settings
These can also be configured via the Settings page in the UI.
@@ -72,16 +157,16 @@ These can also be configured via the Settings page in the UI.
| `IMPORT_MIN_HEIGHT` | `0` | Minimum image height in pixels (0 = no minimum) |
| `IMPORT_SKIP_TRANSPARENT` | `false` | Skip mostly-transparent images (PNG/GIF/WebP) |
| `IMPORT_TRANSPARENCY_THRESHOLD` | `0.9` | Transparency % threshold (0.0-1.0) for skipping |
-| `IMPORT_PHASH_THRESHOLD` | `2` | Duplicate detection sensitivity (0=disabled, 1=strict, 2=normal, 5=loose, 10=very loose) |
+| `IMPORT_PHASH_THRESHOLD` | `10` | Duplicate detection sensitivity (0=disabled, lower=stricter) |
-### Thumbnail Generation
+### Thumbnail & Video Processing
| Variable | Default | Description |
|----------|---------|-------------|
| `THUMBS_VERBOSE` | `0` | Enable verbose logging (`1`, `true`, or `yes`) |
| `THUMBS_LOG_EVERY` | `50` | Log progress every N thumbnails |
-| `THUMBS_BATCH_SIZE` | `500` | Process thumbnails in batches of N |
| `THUMBS_FFMPEG_TIMEOUT` | `60` | Timeout in seconds for video thumbnail extraction |
+| `FFMPEG_TRANSCODE_TIMEOUT` | `600` | Timeout in seconds for video transcoding |
### Archive Extraction
@@ -100,8 +185,12 @@ These can also be configured via the Settings page in the UI.
## Import Behavior
The importer runs:
-- Automatically every 8 hours
-- Manually via the "Trigger Image Import" button in Settings
+- Automatically every 8 hours (configurable via `IMPORT_EVERY_SECONDS`)
+- Manually via the "Trigger Import Scan" button in Settings
+
+### Video Transcoding
+
+Non-MP4 video formats (.mov, .avi, .mkv, .webm, .m4v, .wmv, .flv) are automatically transcoded to H.264 MP4 during import for universal browser playback.
### Folder Structure
@@ -125,7 +214,7 @@ The importer uses the folder structure to auto-tag images:
### Supported Formats
**Images:** PNG, JPG, JPEG, GIF, WebP, BMP, TIFF
-**Videos:** MP4, WebM, MKV, AVI, MOV
+**Videos:** MP4, MOV, AVI, MKV, WebM, M4V, WMV, FLV (transcoded to MP4)
**Archives:** ZIP, RAR, 7z, and others supported by `unar`/`7z`
## Settings Page
@@ -133,8 +222,9 @@ The importer uses the folder structure to auto-tag images:
The Settings page (`/settings`) provides:
### Admin Actions
-- **Trigger Image Import** - Manually start an import scan
+- **Trigger Import Scan** - Manually start an import scan
- **Regenerate Thumbnails** - Rebuild all thumbnails
+- **Find Duplicates** - Scan for visually similar images using pHash
- **Reset Image Database** - Clear all image records (files remain on disk)
### Import Filters
@@ -143,6 +233,11 @@ Configure filtering rules that apply during import:
- Transparency filtering
- Duplicate detection sensitivity
+### Duplicate Management
+- Scan for potential duplicates based on perceptual hash distance
+- Review and confirm duplicates for deletion
+- Keep the higher resolution version automatically
+
### Delete Images by Tag
Bulk delete all images associated with a specific tag (e.g., remove an entire artist's collection).
@@ -156,7 +251,10 @@ Scan and selectively delete images that match filter criteria (transparency or d
| `/api/random-images` | GET | Get random images for showcase |
| `/api/tags/search` | GET | Search tags for autocomplete |
| `/api/import-settings` | GET/POST | Read/write import filter settings |
-| `/api/import-stats` | GET | Get last import statistics |
+| `/api/import/trigger` | POST | Trigger directory scan |
+| `/api/import/status` | GET | Get import queue status |
+| `/api/duplicates/scan` | POST | Scan for duplicate images |
+| `/api/duplicates/delete` | POST | Delete confirmed duplicates |
| `/api/filter-scan` | GET | Scan images matching filter criteria |
| `/api/filter-delete` | POST | Delete selected images |
| `/api/delete-by-tag` | POST | Delete all images with a tag |
@@ -172,38 +270,20 @@ In the image modal:
- **Click image** - Toggle zoom
- **Drag** (when zoomed) - Pan around image
-## PostgreSQL Setup
+## Scaling Workers
-To use PostgreSQL instead of SQLite:
+To increase import throughput:
-```yaml
-services:
- imagerepo:
- image: git.fabledsword.com/bvandeusen/imagerepo:latest
- ports:
- - "5000:5000"
- environment:
- - DB_TYPE=postgresql
- - DB_HOST=postgres
- - DB_USER=imagerepo
- - DB_PASS=your_secure_password
- - DB_NAME=imagerepo
- volumes:
- - ./imagerepo/images:/images
- - ./your-media:/import
- depends_on:
- - postgres
+```bash
+# Scale worker containers
+docker-compose up --scale celery-worker=4
- postgres:
- image: postgres:15
- environment:
- - POSTGRES_USER=imagerepo
- - POSTGRES_PASSWORD=your_secure_password
- - POSTGRES_DB=imagerepo
- volumes:
- - ./postgres-data:/var/lib/postgresql/data
+# Or increase concurrency per worker
+CELERY_WORKER_CONCURRENCY=4 docker-compose up
```
+Total parallelism = containers × concurrency
+
## Development
### Local Setup
@@ -213,6 +293,9 @@ services:
git clone https://git.fabledsword.com/bvandeusen/imagerepo.git
cd imagerepo
+# Start PostgreSQL and Redis
+docker-compose up -d postgres redis
+
# Create virtual environment
python -m venv venv
source venv/bin/activate # or `venv\Scripts\activate` on Windows
@@ -220,6 +303,9 @@ source venv/bin/activate # or `venv\Scripts\activate` on Windows
# Install dependencies
pip install -r requirements.txt
+# Run migrations
+flask db upgrade
+
# Run with Flask dev server
flask run
```
@@ -228,7 +314,6 @@ flask run
```bash
docker build -t imagerepo .
-docker run -p 5000:5000 -v ./images:/images -v ./import:/import imagerepo
```
## License
diff --git a/app/__init__.py b/app/__init__.py
index 58d3bf0..a139396 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -6,9 +6,6 @@ from flask.cli import with_appcontext
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from werkzeug.middleware.proxy_fix import ProxyFix
-from sqlalchemy import event
-from sqlalchemy.engine import Engine
-import sqlite3
db = SQLAlchemy()
migrate = Migrate()
@@ -56,14 +53,6 @@ def celery_beat_command():
'--loglevel=info'
])
-@event.listens_for(Engine, "connect")
-def set_sqlite_pragma(dbapi_connection, connection_record):
- # Only apply to SQLite connections
- if isinstance(dbapi_connection, sqlite3.Connection):
- cursor = dbapi_connection.cursor()
- cursor.execute("PRAGMA foreign_keys = ON")
- cursor.close()
-
def create_app(config_class='config.Config'):
app = Flask(__name__)
app.config.from_object(config_class)
diff --git a/app/main.py b/app/main.py
index 84696e6..60ae78d 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1316,3 +1316,267 @@ def list_import_batches():
for b in batches
]
})
+
+
+# =============================================
+# Duplicate Detection API
+# =============================================
+
+@main.route('/api/duplicates/scan', methods=['POST'])
+def scan_duplicates():
+ """
+ Scan for duplicate images using perceptual hash comparison.
+ Returns groups of similar images within the specified hamming distance.
+ """
+ import imagehash
+
+ threshold = request.form.get('threshold', 10, type=int)
+ limit = request.form.get('limit', 100, type=int)
+
+ # Get all images with perceptual hashes
+ images = ImageRecord.query.filter(
+ ImageRecord.perceptual_hash.isnot(None)
+ ).all()
+
+ if not images:
+ return jsonify({'ok': True, 'groups': [], 'message': 'No images with perceptual hashes found'})
+
+ # Build hash lookup
+ hash_data = []
+ for img in images:
+ try:
+ phash = imagehash.hex_to_hash(img.perceptual_hash)
+ hash_data.append({
+ 'id': img.id,
+ 'hash': phash,
+ 'width': img.width,
+ 'height': img.height,
+ 'filename': img.filename,
+ 'filepath': img.filepath,
+ 'thumb_path': img.thumb_path,
+ 'file_size': img.file_size
+ })
+ except Exception:
+ continue
+
+ # Find duplicate groups
+ duplicate_groups = []
+ processed = set()
+
+ for i, img1 in enumerate(hash_data):
+ if img1['id'] in processed:
+ continue
+
+ group = [img1]
+
+ for j, img2 in enumerate(hash_data[i+1:], start=i+1):
+ if img2['id'] in processed:
+ continue
+
+ distance = img1['hash'] - img2['hash']
+ if distance <= threshold:
+ group.append(img2)
+ processed.add(img2['id'])
+
+ if len(group) > 1:
+ processed.add(img1['id'])
+ # Sort group by resolution (largest first)
+ group.sort(key=lambda x: x['width'] * x['height'], reverse=True)
+ duplicate_groups.append(group)
+
+ if len(duplicate_groups) >= limit:
+ break
+
+ # Format response
+ result_groups = []
+ for group in duplicate_groups:
+ result_groups.append([
+ {
+ 'id': img['id'],
+ 'filename': img['filename'],
+ 'width': img['width'],
+ 'height': img['height'],
+ 'file_size': img['file_size'],
+ 'thumb_url': url_for('main.serve_image',
+ filename=(img['thumb_path'] or img['filepath']).replace('/images/', ''))
+ }
+ for img in group
+ ])
+
+ return jsonify({
+ 'ok': True,
+ 'groups': result_groups,
+ 'total_groups': len(result_groups),
+ 'threshold': threshold
+ })
+
+
+@main.route('/api/duplicates/delete', methods=['POST'])
+def delete_duplicates():
+ """
+ Delete specified duplicate images.
+ Expects JSON body with 'image_ids' array.
+ """
+ data = request.get_json() or {}
+ image_ids = data.get('image_ids', [])
+
+ if not image_ids:
+ return jsonify({'ok': False, 'error': 'No image IDs provided'})
+
+ deleted_count = 0
+ errors = []
+
+ for image_id in image_ids:
+ try:
+ image = ImageRecord.query.get(image_id)
+ if not image:
+ errors.append(f"Image {image_id} not found")
+ continue
+
+ # Delete physical files
+ if image.filepath and os.path.exists(image.filepath):
+ os.remove(image.filepath)
+ if image.thumb_path and os.path.exists(image.thumb_path):
+ os.remove(image.thumb_path)
+
+ # Delete database record
+ db.session.delete(image)
+ deleted_count += 1
+
+ except Exception as e:
+ errors.append(f"Error deleting {image_id}: {str(e)}")
+
+ db.session.commit()
+
+ return jsonify({
+ 'ok': True,
+ 'deleted_count': deleted_count,
+ 'errors': errors if errors else None
+ })
+
+
+# =============================================
+# Bulk Tag Operations API
+# =============================================
+
+@main.route('/api/images/common-tags', methods=['POST'])
+def get_common_tags():
+ """
+ Get tags that are common to all specified images.
+ Expects JSON body with 'image_ids' array.
+ Returns tags that appear on EVERY selected image.
+ """
+ data = request.get_json() or {}
+ image_ids = data.get('image_ids', [])
+
+ if not image_ids:
+ return jsonify({'ok': True, 'tags': []})
+
+ # Find tags that appear on ALL selected images
+ # We use a subquery approach: count occurrences of each tag and filter
+ # for tags where the count equals the number of images
+ num_images = len(image_ids)
+
+ # Query: get tags where the count of associated images (from our list) equals num_images
+ common_tags = db.session.query(Tag).join(image_tags).filter(
+ image_tags.c.image_id.in_(image_ids)
+ ).group_by(Tag.id).having(
+ func.count(image_tags.c.image_id) == num_images
+ ).order_by(Tag.name).all()
+
+ return jsonify({
+ 'ok': True,
+ 'tags': [
+ {'id': t.id, 'name': t.name, 'kind': t.kind}
+ for t in common_tags
+ ]
+ })
+
+
+@main.route('/api/images/bulk-add-tag', methods=['POST'])
+def bulk_add_tag():
+ """
+ Add a tag to multiple images at once.
+ Expects JSON body with 'image_ids' array and 'tag_name' string.
+ """
+ data = request.get_json() or {}
+ image_ids = data.get('image_ids', [])
+ tag_name = (data.get('tag_name') or '').strip()
+
+ if not image_ids:
+ return jsonify({'ok': False, 'error': 'No image IDs provided'}), 400
+
+ if not tag_name:
+ return jsonify({'ok': False, 'error': 'No tag name provided'}), 400
+
+ # Determine tag kind from name
+ if ':' in tag_name:
+ kind = tag_name.split(':', 1)[0]
+ else:
+ kind = 'user'
+
+ # Get or create the tag
+ tag = Tag.query.filter_by(name=tag_name).first()
+ if not tag:
+ tag = Tag(name=tag_name, kind=kind)
+ db.session.add(tag)
+ db.session.flush()
+
+ # Get images that don't already have this tag
+ images = ImageRecord.query.filter(
+ ImageRecord.id.in_(image_ids)
+ ).all()
+
+ added_count = 0
+ for img in images:
+ if tag not in img.tags:
+ img.tags.append(tag)
+ added_count += 1
+
+ db.session.commit()
+
+ return jsonify({
+ 'ok': True,
+ 'added_count': added_count,
+ 'tag': {'id': tag.id, 'name': tag.name, 'kind': tag.kind}
+ })
+
+
+@main.route('/api/images/bulk-remove-tag', methods=['POST'])
+def bulk_remove_tag():
+ """
+ Remove a tag from multiple images at once.
+ Expects JSON body with 'image_ids' array and 'tag_name' string.
+ """
+ data = request.get_json() or {}
+ image_ids = data.get('image_ids', [])
+ tag_name = (data.get('tag_name') or '').strip()
+
+ if not image_ids:
+ return jsonify({'ok': False, 'error': 'No image IDs provided'}), 400
+
+ if not tag_name:
+ return jsonify({'ok': False, 'error': 'No tag name provided'}), 400
+
+ # Find the tag
+ tag = Tag.query.filter_by(name=tag_name).first()
+ if not tag:
+ return jsonify({'ok': True, 'removed_count': 0, 'message': 'Tag not found'})
+
+ # Get images that have this tag
+ images = ImageRecord.query.filter(
+ ImageRecord.id.in_(image_ids)
+ ).all()
+
+ removed_count = 0
+ for img in images:
+ if tag in img.tags:
+ img.tags.remove(tag)
+ removed_count += 1
+
+ db.session.commit()
+
+ return jsonify({
+ 'ok': True,
+ 'removed_count': removed_count
+ })
diff --git a/app/static/js/bulk-select.js b/app/static/js/bulk-select.js
new file mode 100644
index 0000000..ccc1a1b
--- /dev/null
+++ b/app/static/js/bulk-select.js
@@ -0,0 +1,421 @@
+// /app/static/js/bulk-select.js
+// Multi-select mode controller for bulk tag operations
+// Works with gallery-infinite.js and showcase.js
+
+(function() {
+ 'use strict';
+
+ const state = {
+ isSelectMode: false,
+ selectedIds: new Set()
+ };
+
+ // DOM elements (populated on init)
+ const dom = {};
+
+ // Autocomplete state
+ let autocompleteItems = [];
+ let autocompleteSelectedIndex = -1;
+ let autocompleteDebounce = null;
+
+ function init() {
+ dom.selectBtn = document.getElementById('selectModeBtn');
+ dom.panel = document.getElementById('bulkEditorPanel');
+ dom.closeBtn = document.getElementById('closeBulkEditor');
+ dom.countEl = document.getElementById('selectedCount');
+ dom.clearBtn = document.getElementById('clearSelectionBtn');
+ dom.addForm = document.getElementById('bulkAddTagForm');
+ dom.tagInput = document.getElementById('bulkTagInput');
+ dom.autocomplete = document.getElementById('bulkTagAutocomplete');
+ dom.commonTags = document.getElementById('commonTagsList');
+
+ if (!dom.selectBtn || !dom.panel) return;
+
+ setupEventListeners();
+ }
+
+ function setupEventListeners() {
+ // Toggle select mode
+ dom.selectBtn.addEventListener('click', toggleSelectMode);
+ dom.closeBtn?.addEventListener('click', exitSelectMode);
+ dom.clearBtn?.addEventListener('click', clearSelection);
+
+ // Image click handling (delegated) - use capture phase to intercept before modal
+ document.addEventListener('click', handleImageClick, true);
+
+ // Bulk add tag form
+ dom.addForm?.addEventListener('submit', handleBulkAddTag);
+
+ // Tag autocomplete
+ setupAutocomplete();
+
+ // Listen for gallery updates (new images loaded)
+ document.addEventListener('galleryUpdated', updateSelectionUI);
+ document.addEventListener('showcaseUpdated', updateSelectionUI);
+
+ // Keyboard shortcut: Escape to exit select mode
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && state.isSelectMode) {
+ // Only exit if modal is not open
+ const modal = document.getElementById('imageModal');
+ if (!modal || !modal.classList.contains('active')) {
+ exitSelectMode();
+ }
+ }
+ });
+ }
+
+ function toggleSelectMode() {
+ state.isSelectMode = !state.isSelectMode;
+ document.body.classList.toggle('select-mode', state.isSelectMode);
+ dom.selectBtn.textContent = state.isSelectMode ? 'Cancel' : 'Select';
+ dom.selectBtn.classList.toggle('active', state.isSelectMode);
+
+ if (state.isSelectMode) {
+ dom.panel.classList.add('active');
+ updateCommonTagsDisplay();
+ } else {
+ dom.panel.classList.remove('active');
+ clearSelection();
+ }
+ }
+
+ function exitSelectMode() {
+ if (state.isSelectMode) {
+ toggleSelectMode();
+ }
+ }
+
+ function handleImageClick(e) {
+ if (!state.isSelectMode) return;
+
+ const item = e.target.closest('.img-clickable');
+ if (!item) return;
+
+ // Don't interfere with tag chip clicks
+ if (e.target.closest('.tag-chip')) return;
+
+ // Prevent modal from opening in select mode
+ e.stopPropagation();
+ e.preventDefault();
+
+ const id = parseInt(item.dataset.id);
+ if (isNaN(id)) return;
+
+ if (state.selectedIds.has(id)) {
+ state.selectedIds.delete(id);
+ item.classList.remove('selected');
+ } else {
+ state.selectedIds.add(id);
+ item.classList.add('selected');
+ }
+
+ updateCount();
+ loadCommonTags();
+ }
+
+ function clearSelection() {
+ state.selectedIds.clear();
+ document.querySelectorAll('.img-clickable.selected').forEach(el => {
+ el.classList.remove('selected');
+ });
+ updateCount();
+ updateCommonTagsDisplay();
+ }
+
+ function updateCount() {
+ if (dom.countEl) {
+ dom.countEl.textContent = state.selectedIds.size;
+ }
+ }
+
+ function updateSelectionUI() {
+ // Re-apply selected class to any newly loaded images that are in selection
+ document.querySelectorAll('.img-clickable').forEach(item => {
+ const id = parseInt(item.dataset.id);
+ if (state.selectedIds.has(id)) {
+ item.classList.add('selected');
+ }
+ });
+ }
+
+ function updateCommonTagsDisplay() {
+ if (state.selectedIds.size === 0) {
+ if (dom.commonTags) {
+ dom.commonTags.innerHTML = 'No images selected';
+ }
+ }
+ }
+
+ async function loadCommonTags() {
+ if (state.selectedIds.size === 0) {
+ updateCommonTagsDisplay();
+ return;
+ }
+
+ const ids = Array.from(state.selectedIds);
+
+ try {
+ const response = await fetch('/api/images/common-tags', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image_ids: ids })
+ });
+ const data = await response.json();
+
+ if (data.ok) {
+ renderCommonTags(data.tags);
+ }
+ } catch (err) {
+ console.error('Failed to load common tags:', err);
+ }
+ }
+
+ function renderCommonTags(tags) {
+ if (!dom.commonTags) return;
+
+ if (!tags || tags.length === 0) {
+ dom.commonTags.innerHTML = 'No common tags';
+ return;
+ }
+
+ dom.commonTags.innerHTML = tags.map(t => {
+ const icon = getTagIcon(t.kind);
+ const display = t.name.includes(':') ? t.name.split(':')[1] : t.name;
+ const escapedName = escapeHtml(t.name);
+ const escapedDisplay = escapeHtml(display);
+ return `
+ ${icon} ${escapedDisplay}
+
+ `;
+ }).join('');
+
+ // Add remove handlers
+ dom.commonTags.querySelectorAll('.tag-chip .x').forEach(btn => {
+ btn.addEventListener('click', async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const chip = e.target.closest('.tag-chip');
+ const name = chip.dataset.name;
+ chip.style.opacity = '0.5';
+ await bulkRemoveTag(name);
+ });
+ });
+ }
+
+ async function handleBulkAddTag(e) {
+ e.preventDefault();
+ const name = dom.tagInput.value.trim();
+ if (!name || state.selectedIds.size === 0) return;
+
+ dom.tagInput.disabled = true;
+ await bulkAddTag(name);
+ dom.tagInput.value = '';
+ dom.tagInput.disabled = false;
+ dom.tagInput.focus();
+ hideAutocomplete();
+ }
+
+ async function bulkAddTag(name) {
+ const ids = Array.from(state.selectedIds);
+
+ try {
+ const response = await fetch('/api/images/bulk-add-tag', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image_ids: ids, tag_name: name })
+ });
+ const data = await response.json();
+
+ if (data.ok) {
+ loadCommonTags(); // Refresh common tags
+ } else {
+ console.error('Failed to add tag:', data.error);
+ }
+ } catch (err) {
+ console.error('Failed to add tag:', err);
+ }
+ }
+
+ async function bulkRemoveTag(name) {
+ const ids = Array.from(state.selectedIds);
+
+ try {
+ const response = await fetch('/api/images/bulk-remove-tag', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ image_ids: ids, tag_name: name })
+ });
+ const data = await response.json();
+
+ if (data.ok) {
+ loadCommonTags(); // Refresh common tags
+ } else {
+ console.error('Failed to remove tag:', data.error);
+ }
+ } catch (err) {
+ console.error('Failed to remove tag:', err);
+ }
+ }
+
+ // ---------------------------
+ // Autocomplete
+ // ---------------------------
+ function setupAutocomplete() {
+ if (!dom.tagInput || !dom.autocomplete) return;
+
+ // Show autocomplete on focus
+ dom.tagInput.addEventListener('focus', () => {
+ const val = dom.tagInput.value.trim();
+ fetchAutocomplete(val);
+ });
+
+ // Hide autocomplete on blur (with delay for click)
+ dom.tagInput.addEventListener('blur', () => {
+ setTimeout(hideAutocomplete, 200);
+ });
+
+ // Search as user types
+ dom.tagInput.addEventListener('input', () => {
+ clearTimeout(autocompleteDebounce);
+ autocompleteDebounce = setTimeout(() => {
+ fetchAutocomplete(dom.tagInput.value.trim());
+ }, 150);
+ });
+
+ // Keyboard navigation
+ dom.tagInput.addEventListener('keydown', (e) => {
+ if (!dom.autocomplete.classList.contains('active')) return;
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ const nextIndex = Math.min(autocompleteSelectedIndex + 1, autocompleteItems.length - 1);
+ selectAutocompleteItem(nextIndex);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ const prevIndex = Math.max(autocompleteSelectedIndex - 1, 0);
+ selectAutocompleteItem(prevIndex);
+ } else if (e.key === 'Enter' && autocompleteSelectedIndex >= 0) {
+ e.preventDefault();
+ const selected = autocompleteItems[autocompleteSelectedIndex];
+ if (selected) {
+ dom.tagInput.value = selected.name;
+ hideAutocomplete();
+ // Submit the form
+ dom.addForm.dispatchEvent(new Event('submit', { cancelable: true }));
+ }
+ } else if (e.key === 'Escape') {
+ hideAutocomplete();
+ }
+ });
+
+ // Click on autocomplete item
+ dom.autocomplete.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const item = e.target.closest('.tag-autocomplete-item');
+ if (!item) return;
+ const name = item.dataset.name;
+ if (name) {
+ dom.tagInput.value = name;
+ hideAutocomplete();
+ dom.addForm.dispatchEvent(new Event('submit', { cancelable: true }));
+ }
+ });
+ }
+
+ function hideAutocomplete() {
+ if (dom.autocomplete) {
+ dom.autocomplete.classList.remove('active');
+ dom.autocomplete.innerHTML = '';
+ }
+ autocompleteItems = [];
+ autocompleteSelectedIndex = -1;
+ }
+
+ function positionAutocomplete() {
+ if (!dom.autocomplete || !dom.tagInput) return;
+ const rect = dom.tagInput.getBoundingClientRect();
+ dom.autocomplete.style.top = `${rect.bottom + 4}px`;
+ dom.autocomplete.style.left = `${rect.left}px`;
+ dom.autocomplete.style.width = `${rect.width}px`;
+ }
+
+ async function fetchAutocomplete(query) {
+ try {
+ // Exclude system-managed tag kinds from autocomplete
+ const excludeKinds = 'archive,post,source';
+ const url = query
+ ? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8&exclude_kind=${excludeKinds}`
+ : `/api/tags/search?limit=8&exclude_kind=${excludeKinds}`;
+ const r = await fetch(url);
+ const j = await r.json();
+ renderAutocomplete(j.tags || []);
+ } catch {
+ hideAutocomplete();
+ }
+ }
+
+ function renderAutocomplete(tags) {
+ if (!dom.autocomplete) return;
+ autocompleteItems = tags;
+ autocompleteSelectedIndex = -1;
+
+ if (tags.length === 0) {
+ dom.autocomplete.innerHTML = '
No matching tags
';
+ positionAutocomplete();
+ dom.autocomplete.classList.add('active');
+ return;
+ }
+
+ dom.autocomplete.innerHTML = tags.map((t, i) => {
+ const icon = getTagIcon(t.kind);
+ const displayName = t.name.includes(':') ? t.name.split(':')[1] : t.name;
+ return `
+ ${icon} ${escapeHtml(displayName)}
+ ${t.kind || 'user'}
+
`;
+ }).join('');
+
+ positionAutocomplete();
+ dom.autocomplete.classList.add('active');
+ }
+
+ function selectAutocompleteItem(index) {
+ const items = dom.autocomplete?.querySelectorAll('.tag-autocomplete-item');
+ if (!items) return;
+ items.forEach((el, i) => {
+ el.classList.toggle('selected', i === index);
+ });
+ autocompleteSelectedIndex = index;
+ }
+
+ // ---------------------------
+ // Helpers
+ // ---------------------------
+ function getTagIcon(kind) {
+ const icons = {
+ artist: '🎨',
+ archive: '🗜️',
+ character: '👤',
+ series: '📺',
+ rating: '⚠️',
+ source: '🌐',
+ post: '📌'
+ };
+ return icons[kind] || '#';
+ }
+
+ function escapeHtml(str) {
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+ }
+
+ // Initialize on DOM ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();
diff --git a/app/static/style.css b/app/static/style.css
index d57930b..8ca997f 100644
--- a/app/static/style.css
+++ b/app/static/style.css
@@ -1507,3 +1507,229 @@ select.form-input optgroup {
top: 45px;
}
}
+
+/*------------------------------------------------------------------------------
+ Gallery Header Layout
+------------------------------------------------------------------------------*/
+.gallery-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.gallery-header-left {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.gallery-header-left h1 {
+ margin: 0;
+}
+
+.gallery-header-actions {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+/*------------------------------------------------------------------------------
+ Bulk Select Mode
+------------------------------------------------------------------------------*/
+/* Select mode body state */
+body.select-mode .img-clickable {
+ cursor: pointer;
+ position: relative;
+}
+
+/* Selection checkbox overlay */
+body.select-mode .img-clickable::before {
+ content: '';
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ width: 24px;
+ height: 24px;
+ border: 2px solid rgba(255, 255, 255, 0.8);
+ border-radius: 4px;
+ background: rgba(0, 0, 0, 0.4);
+ z-index: 10;
+ transition: all 0.15s ease;
+}
+
+body.select-mode .img-clickable.selected::before {
+ background: var(--btn-primary);
+ border-color: var(--btn-primary);
+}
+
+body.select-mode .img-clickable.selected::after {
+ content: '\2713';
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ width: 24px;
+ height: 24px;
+ color: white;
+ font-size: 14px;
+ font-weight: bold;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 11;
+}
+
+/* Selected image highlight */
+body.select-mode .img-clickable.selected {
+ outline: 3px solid var(--btn-primary);
+ outline-offset: -3px;
+}
+
+/* Prevent hover effects in select mode */
+body.select-mode .gallery-item:hover {
+ transform: none;
+}
+
+/* Select button active state */
+#selectModeBtn.active {
+ background: var(--btn-primary);
+ color: white;
+}
+
+/*------------------------------------------------------------------------------
+ Bulk Editor Panel (slide-in sidebar)
+------------------------------------------------------------------------------*/
+.bulk-editor-panel {
+ position: fixed;
+ top: 0;
+ right: 0;
+ width: 320px;
+ height: 100vh;
+ background: var(--panel);
+ border-left: 1px solid rgba(255, 255, 255, 0.1);
+ z-index: 1100;
+ transform: translateX(100%);
+ transition: transform 0.3s ease;
+ display: flex;
+ flex-direction: column;
+ box-shadow: var(--shadow-3);
+}
+
+.bulk-editor-panel.active {
+ transform: translateX(0);
+}
+
+.bulk-editor-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1rem;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.bulk-editor-header h3 {
+ margin: 0;
+ font-size: 1.1rem;
+}
+
+.bulk-editor-stats {
+ padding: 0.75rem 1rem;
+ background: rgba(255, 255, 255, 0.05);
+ font-size: 0.9rem;
+ color: var(--text-dim);
+}
+
+.bulk-editor-section {
+ padding: 1rem;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.bulk-editor-section h4 {
+ margin: 0 0 0.75rem 0;
+ font-size: 0.9rem;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.bulk-editor-section .form-help {
+ margin: 0 0 0.5rem 0;
+ font-size: 0.8rem;
+ color: var(--text-muted);
+}
+
+.bulk-editor-section .tags {
+ min-height: 32px;
+}
+
+.bulk-editor-actions {
+ padding: 1rem;
+ margin-top: auto;
+}
+
+/* Removable tag chips in bulk editor */
+.bulk-editor-section .tag-chip.removable {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 8px;
+ background: rgba(255, 255, 255, 0.12);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ color: var(--text);
+ cursor: default;
+}
+
+.bulk-editor-section .tag-chip.removable:hover {
+ background: rgba(255, 255, 255, 0.18);
+}
+
+.bulk-editor-section .tag-chip .x {
+ background: none;
+ border: none;
+ color: rgba(255, 255, 255, 0.5);
+ cursor: pointer;
+ padding: 0;
+ margin-left: 2px;
+ font-size: 1rem;
+ line-height: 1;
+}
+
+.bulk-editor-section .tag-chip .x:hover {
+ color: #ef4444;
+}
+
+/* Text muted helper */
+.text-muted {
+ color: var(--text-muted);
+ font-size: 0.85rem;
+}
+
+/* Mobile: full-width panel from bottom */
+@media (max-width: 640px) {
+ .bulk-editor-panel {
+ width: 100%;
+ height: auto;
+ max-height: 60vh;
+ top: auto;
+ bottom: 0;
+ transform: translateY(100%);
+ border-left: none;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 16px 16px 0 0;
+ }
+
+ .bulk-editor-panel.active {
+ transform: translateY(0);
+ }
+
+ .gallery-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .gallery-header-actions {
+ justify-content: flex-end;
+ }
+}
diff --git a/app/templates/gallery.html b/app/templates/gallery.html
index ad32a5f..b44da78 100644
--- a/app/templates/gallery.html
+++ b/app/templates/gallery.html
@@ -15,13 +15,18 @@
@@ -61,6 +66,35 @@
{% include '_gallery_modal.html' %}
+
+
+
+
{% endblock %}
diff --git a/app/templates/settings.html b/app/templates/settings.html
index 16bff44..21a8719 100644
--- a/app/templates/settings.html
+++ b/app/templates/settings.html
@@ -206,6 +206,46 @@
+
+
+
+
Duplicate Detection
+
Scan for visually similar images using perceptual hash comparison. The first image in each group has the highest resolution and will be kept.
+
+
+
+
+
+
Scan Results
+
Found 0 group(s) of potential duplicates.
+
+
+
+
+
+
+ Selected for deletion: 0 image(s)
+ (The highest resolution image in each group is protected)
+
+
+
+
+
+
+
@@ -256,7 +296,6 @@
-
Open Flower Dashboard
@@ -492,6 +531,83 @@
transform: translateY(0);
}
}
+ /* Duplicate detection styles */
+ .dup-group {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ padding: 1rem;
+ margin-bottom: 1rem;
+ }
+ .dup-group-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.75rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ }
+ .dup-group-header h4 {
+ margin: 0;
+ font-size: 0.9rem;
+ color: var(--text-dim);
+ }
+ .dup-group-images {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ }
+ .dup-image {
+ position: relative;
+ width: 150px;
+ border-radius: 6px;
+ overflow: hidden;
+ cursor: pointer;
+ border: 2px solid transparent;
+ transition: border-color 0.2s ease;
+ }
+ .dup-image.selected {
+ border-color: var(--btn-danger);
+ }
+ .dup-image.protected {
+ border-color: var(--btn-primary);
+ cursor: default;
+ }
+ .dup-image img {
+ width: 100%;
+ aspect-ratio: 1;
+ object-fit: cover;
+ display: block;
+ }
+ .dup-image-info {
+ background: rgba(0, 0, 0, 0.75);
+ padding: 0.35rem 0.5rem;
+ font-size: 0.7rem;
+ color: #fff;
+ }
+ .dup-image-info .resolution {
+ font-weight: 600;
+ }
+ .dup-image-info .size {
+ color: rgba(255, 255, 255, 0.7);
+ }
+ .dup-badge {
+ position: absolute;
+ top: 4px;
+ left: 4px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 0.65rem;
+ font-weight: 600;
+ }
+ .dup-badge.keep {
+ background: var(--btn-primary);
+ color: #fff;
+ }
+ .dup-badge.delete {
+ background: var(--btn-danger);
+ color: #fff;
+ }
{% endblock %}
diff --git a/config.py b/config.py
index aacdf2c..98e1b5d 100644
--- a/config.py
+++ b/config.py
@@ -2,29 +2,17 @@ import os
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'you-will-never-guess')
- DB_TYPE = os.environ.get('DB_TYPE', 'sqlite').lower() # 'sqlite' or 'postgresql'
- if DB_TYPE == 'postgresql':
- DB_USER = os.environ.get('DB_USER', 'imagerepo')
- DB_PASS = os.environ.get('DB_PASS', 'postgres')
- DB_HOST = os.environ.get('DB_HOST', 'postgres')
- DB_PORT = os.environ.get('DB_PORT', '5432')
- DB_NAME = os.environ.get('DB_NAME', 'imagerepo')
+ # PostgreSQL configuration
+ DB_USER = os.environ.get('DB_USER', 'imagerepo')
+ DB_PASS = os.environ.get('DB_PASS', 'postgres')
+ DB_HOST = os.environ.get('DB_HOST', 'postgres')
+ DB_PORT = os.environ.get('DB_PORT', '5432')
+ DB_NAME = os.environ.get('DB_NAME', 'imagerepo')
- SQLALCHEMY_DATABASE_URI = (
- f'postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}'
- )
-
- elif DB_TYPE == 'sqlite':
- DB_DIR = '/db'
- os.makedirs(DB_DIR, exist_ok=True) # only works if /db is writable
- DB_NAME = os.environ.get('DB_NAME', 'app.db')
- SQLALCHEMY_DATABASE_URI = f'sqlite:///{os.path.join(DB_DIR, DB_NAME)}'
-
-
-
- else:
- raise ValueError(f"Unsupported DB_TYPE '{DB_TYPE}'. Use 'sqlite' or 'postgresql'.")
+ SQLALCHEMY_DATABASE_URI = (
+ f'postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}'
+ )
SQLALCHEMY_TRACK_MODIFICATIONS = False
diff --git a/docker-compose.sqlite.yml b/docker-compose.sqlite.yml
deleted file mode 100644
index 1d0191d..0000000
--- a/docker-compose.sqlite.yml
+++ /dev/null
@@ -1,86 +0,0 @@
-# docker-compose.sqlite.yml
-# Development/testing configuration using SQLite instead of PostgreSQL
-# Usage: docker-compose -f docker-compose.sqlite.yml up
-#
-# NOTE: SQLite does not support concurrent writes well.
-# This configuration is for development/testing only.
-# For production, use the main docker-compose.yml with PostgreSQL.
-
-services:
- # Redis message broker for Celery
- redis:
- image: redis:7-alpine
- ports:
- - "6379:6379"
- healthcheck:
- test: ["CMD", "redis-cli", "ping"]
- interval: 10s
- timeout: 5s
- retries: 5
- restart: unless-stopped
-
- # Flask web application (SQLite mode)
- web:
- build: .
- ports:
- - "5000:5000"
- environment:
- - DB_TYPE=sqlite
- - CELERY_BROKER_URL=redis://redis:6379/0
- - CELERY_RESULT_BACKEND=redis://redis:6379/0
- - RUN_IMAGE_IMPORTER=0 # Disable old polling worker
- volumes:
- - ./imagerepo/db/:/db
- - ./imagerepo/images:/images
- - ./import:/import
- - ./migrations:/app/migrations
- depends_on:
- redis:
- condition: service_healthy
- restart: unless-stopped
-
- # Single Celery worker (SQLite needs single worker to avoid write conflicts)
- celery-worker:
- build: .
- command: celery -A app.celery_app:celery worker --loglevel=info -Q scan,import,thumbnail,sidecar,default --concurrency=1
- environment:
- - DB_TYPE=sqlite
- - CELERY_BROKER_URL=redis://redis:6379/0
- - CELERY_RESULT_BACKEND=redis://redis:6379/0
- volumes:
- - ./imagerepo/db/:/db
- - ./imagerepo/images:/images
- - ./import:/import
- depends_on:
- redis:
- condition: service_healthy
- restart: unless-stopped
-
- # Celery Beat scheduler (optional for development)
- celery-beat:
- build: .
- command: celery -A app.celery_app:celery beat --loglevel=info
- environment:
- - DB_TYPE=sqlite
- - CELERY_BROKER_URL=redis://redis:6379/0
- - CELERY_RESULT_BACKEND=redis://redis:6379/0
- - IMPORT_EVERY_SECONDS=28800
- volumes:
- - ./imagerepo/db/:/db
- depends_on:
- - redis
- - celery-worker
- restart: unless-stopped
-
- # Flower monitoring (optional)
- flower:
- build: .
- command: celery -A app.celery_app:celery flower --port=5555
- ports:
- - "5555:5555"
- environment:
- - CELERY_BROKER_URL=redis://redis:6379/0
- - CELERY_RESULT_BACKEND=redis://redis:6379/0
- depends_on:
- - redis
- restart: unless-stopped
diff --git a/docker-compose.yml b/docker-compose.yml
index 59fc45c..42655b9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -13,7 +13,7 @@ services:
retries: 5
restart: unless-stopped
- # PostgreSQL database (production-ready)
+ # PostgreSQL database
postgres:
image: postgres:16-alpine
environment:
@@ -35,7 +35,6 @@ services:
ports:
- "5000:5000"
environment:
- - DB_TYPE=postgresql
- DB_USER=${DB_USER:-imagerepo}
- DB_PASS=${DB_PASS:-postgres}
- DB_HOST=postgres
@@ -44,7 +43,6 @@ services:
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
volumes:
- - ./imagerepo/db/:/db # Legacy SQLite path (not used with PostgreSQL)
- ./imagerepo/images:/images
- ./import:/import
- ./migrations:/app/migrations # For dev
@@ -60,7 +58,6 @@ services:
build: .
command: celery -A app.celery_app:celery worker --loglevel=info -Q scan,import,thumbnail,sidecar,default --concurrency=${CELERY_WORKER_CONCURRENCY:-2}
environment:
- - DB_TYPE=postgresql
- DB_USER=${DB_USER:-imagerepo}
- DB_PASS=${DB_PASS:-postgres}
- DB_HOST=postgres
@@ -84,7 +81,6 @@ services:
build: .
command: celery -A app.celery_app:celery beat --loglevel=info
environment:
- - DB_TYPE=postgresql
- DB_USER=${DB_USER:-imagerepo}
- DB_PASS=${DB_PASS:-postgres}
- DB_HOST=postgres