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.

+ +
+
+ + +

Lower values find only very similar images. Higher values find more potential duplicates but may include false positives.

+
+ + +
+ + +
+
+
@@ -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