moving styling and views to be more consistent and for gallery view to be less cumbersome.
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
# app/__init__.py
|
# app/__init__.py
|
||||||
|
|
||||||
|
import click
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
from flask.cli import with_appcontext
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
@@ -11,6 +13,18 @@ import sqlite3
|
|||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
migrate = Migrate()
|
migrate = Migrate()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CLI Commands
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@click.command("version-check")
|
||||||
|
@with_appcontext
|
||||||
|
def version_check_command():
|
||||||
|
"""Check app version and run data migrations if needed (e.g., phash recomputation)."""
|
||||||
|
from app.utils.version_migration import check_and_run_migrations
|
||||||
|
check_and_run_migrations()
|
||||||
|
|
||||||
@event.listens_for(Engine, "connect")
|
@event.listens_for(Engine, "connect")
|
||||||
def set_sqlite_pragma(dbapi_connection, connection_record):
|
def set_sqlite_pragma(dbapi_connection, connection_record):
|
||||||
# Only apply to SQLite connections
|
# Only apply to SQLite connections
|
||||||
@@ -38,4 +52,7 @@ def create_app(config_class='config.Config'):
|
|||||||
def datetimeformat(value, format="%Y-%m-%d"):
|
def datetimeformat(value, format="%Y-%m-%d"):
|
||||||
return value.strftime(format) if value else ""
|
return value.strftime(format) if value else ""
|
||||||
|
|
||||||
|
# Register CLI commands
|
||||||
|
app.cli.add_command(version_check_command)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
+289
-16
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request, jsonify
|
from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request, jsonify
|
||||||
|
|
||||||
from sqlalchemy import desc, func, text
|
from sqlalchemy import desc, func, text, extract
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
from collections import OrderedDict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from app.models import ImageRecord, Tag, ArchiveRecord, image_tags
|
from app.models import ImageRecord, Tag, ArchiveRecord, image_tags
|
||||||
from app import db
|
from app import db
|
||||||
@@ -78,35 +80,47 @@ def random_images_api():
|
|||||||
@main.route('/gallery')
|
@main.route('/gallery')
|
||||||
def gallery():
|
def gallery():
|
||||||
"""
|
"""
|
||||||
Gallery with pagination and optional tag filter (?tag=artist:NAME or any tag name).
|
Gallery with infinite scroll and year-month date dividers.
|
||||||
Keeps your pagination shape so modal-pagination.js continues to work.
|
Server renders initial batch, then JS takes over for infinite scroll.
|
||||||
|
Optional tag filter via ?tag=artist:NAME or any tag name.
|
||||||
"""
|
"""
|
||||||
page = request.args.get('page', 1, type=int)
|
initial_limit = 30
|
||||||
per_page = request.args.get('per_page', 35, type=int)
|
|
||||||
tag_name = request.args.get('tag')
|
tag_name = request.args.get('tag')
|
||||||
|
|
||||||
# Base query + eager-load tags (cheap and future-proofs tag chips)
|
# Base query + eager-load tags
|
||||||
q = ImageRecord.query.options(joinedload(ImageRecord.tags))
|
q = ImageRecord.query.options(joinedload(ImageRecord.tags))
|
||||||
|
|
||||||
# Optional tag filter
|
# Optional tag filter
|
||||||
if tag_name:
|
if tag_name:
|
||||||
q = q.join(ImageRecord.tags).filter(Tag.name == tag_name)
|
q = q.join(ImageRecord.tags).filter(Tag.name == tag_name)
|
||||||
|
|
||||||
images = q.order_by(desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))) \
|
# Fetch initial batch ordered by date descending
|
||||||
.paginate(page=page, per_page=per_page)
|
images = q.order_by(
|
||||||
|
desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))
|
||||||
|
).limit(initial_limit + 1).all()
|
||||||
|
|
||||||
# Preserve next/prev URLs (carry tag= so modal pagination fetches stay filtered)
|
# Check if there are more images
|
||||||
next_url = url_for('main.gallery', page=images.next_num, per_page=per_page, tag=tag_name) if images.has_next else ''
|
has_more = len(images) > initial_limit
|
||||||
prev_url = url_for('main.gallery', page=images.prev_num, per_page=per_page, tag=tag_name) if images.has_prev else ''
|
if has_more:
|
||||||
|
images = images[:initial_limit]
|
||||||
|
|
||||||
|
# Group by year-month for initial render
|
||||||
|
grouped_images = _group_images_by_month(images)
|
||||||
|
|
||||||
|
# Compute initial cursor for JS to continue from
|
||||||
|
initial_cursor = None
|
||||||
|
if images and has_more:
|
||||||
|
last_img = images[-1]
|
||||||
|
last_dt = _get_image_date(last_img)
|
||||||
|
if last_dt:
|
||||||
|
initial_cursor = last_dt.isoformat()
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'gallery.html',
|
'gallery.html',
|
||||||
images=images,
|
grouped_images=grouped_images,
|
||||||
active_tag=tag_name,
|
active_tag=tag_name,
|
||||||
has_next=images.has_next,
|
initial_cursor=initial_cursor,
|
||||||
next_page_url=next_url,
|
has_more=has_more
|
||||||
has_prev=images.has_prev,
|
|
||||||
prev_page_url=prev_url
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -115,6 +129,265 @@ def serve_image(filename):
|
|||||||
return send_from_directory('/images', filename)
|
return send_from_directory('/images', filename)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Gallery Infinite Scroll API
|
||||||
|
# ----------------------------
|
||||||
|
|
||||||
|
def _get_image_date(img):
|
||||||
|
"""Get the effective date for an image (taken_at or imported_at)."""
|
||||||
|
return img.taken_at or img.imported_at
|
||||||
|
|
||||||
|
|
||||||
|
def _format_year_month(dt):
|
||||||
|
"""Format a datetime as year-month key and label."""
|
||||||
|
if not dt:
|
||||||
|
return 'unknown', 'Unknown Date'
|
||||||
|
return dt.strftime('%Y-%m'), dt.strftime('%B %Y')
|
||||||
|
|
||||||
|
|
||||||
|
def _group_images_by_month(images):
|
||||||
|
"""
|
||||||
|
Group images by year-month, preserving order.
|
||||||
|
Returns list of (group_info, images) tuples.
|
||||||
|
"""
|
||||||
|
groups = OrderedDict()
|
||||||
|
|
||||||
|
for img in images:
|
||||||
|
dt = _get_image_date(img)
|
||||||
|
key, label = _format_year_month(dt)
|
||||||
|
|
||||||
|
if key not in groups:
|
||||||
|
groups[key] = {'key': key, 'label': label, 'images': []}
|
||||||
|
|
||||||
|
groups[key]['images'].append(img)
|
||||||
|
|
||||||
|
return [(info['key'], info['label'], info['images']) for info in groups.values()]
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_image(img):
|
||||||
|
"""Serialize an ImageRecord for JSON response."""
|
||||||
|
is_video = img.filename.lower().endswith(('.mp4', '.mov'))
|
||||||
|
thumb_path = (img.thumb_path or img.filepath).replace('/images/', '')
|
||||||
|
full_path = img.filepath.replace('/images/', '')
|
||||||
|
dt = _get_image_date(img)
|
||||||
|
key, label = _format_year_month(dt)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': img.id,
|
||||||
|
'filename': img.filename,
|
||||||
|
'thumb_url': url_for('main.serve_image', filename=thumb_path),
|
||||||
|
'full_url': url_for('main.serve_image', filename=full_path),
|
||||||
|
'is_video': is_video,
|
||||||
|
'date': dt.strftime('%Y-%m-%d') if dt else '',
|
||||||
|
'year_month': key,
|
||||||
|
'year_month_label': label,
|
||||||
|
'tags': [{'name': t.name, 'kind': t.kind} for t in img.tags if t.kind != 'archive']
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@main.route('/api/gallery/scroll')
|
||||||
|
def gallery_scroll():
|
||||||
|
"""
|
||||||
|
Cursor-based infinite scroll endpoint with date grouping.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- cursor: ISO datetime string for pagination (fetch images older than this)
|
||||||
|
- limit: number of images per batch (default 30)
|
||||||
|
- tag: optional tag filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- images: list of image objects with year_month grouping info
|
||||||
|
- next_cursor: datetime string for next batch (null if no more)
|
||||||
|
- date_groups: summary of year-month groups in this batch
|
||||||
|
- has_more: boolean indicating if more images exist
|
||||||
|
"""
|
||||||
|
cursor_str = request.args.get('cursor')
|
||||||
|
limit = request.args.get('limit', 30, type=int)
|
||||||
|
tag_name = request.args.get('tag')
|
||||||
|
|
||||||
|
# Build base query
|
||||||
|
q = ImageRecord.query.options(joinedload(ImageRecord.tags))
|
||||||
|
|
||||||
|
# Optional tag filter
|
||||||
|
if tag_name:
|
||||||
|
q = q.join(ImageRecord.tags).filter(Tag.name == tag_name)
|
||||||
|
|
||||||
|
# Apply cursor filter (fetch images older than cursor)
|
||||||
|
if cursor_str:
|
||||||
|
try:
|
||||||
|
cursor_dt = datetime.fromisoformat(cursor_str.replace('Z', '+00:00'))
|
||||||
|
q = q.filter(
|
||||||
|
func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at) < cursor_dt
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
pass # Invalid cursor, ignore
|
||||||
|
|
||||||
|
# Order by date descending and fetch with limit + 1 to check has_more
|
||||||
|
images = q.order_by(
|
||||||
|
desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))
|
||||||
|
).limit(limit + 1).all()
|
||||||
|
|
||||||
|
# Check if there are more images
|
||||||
|
has_more = len(images) > limit
|
||||||
|
if has_more:
|
||||||
|
images = images[:limit]
|
||||||
|
|
||||||
|
# Serialize images
|
||||||
|
serialized = [_serialize_image(img) for img in images]
|
||||||
|
|
||||||
|
# Build date_groups summary
|
||||||
|
groups_seen = OrderedDict()
|
||||||
|
for s in serialized:
|
||||||
|
key = s['year_month']
|
||||||
|
if key not in groups_seen:
|
||||||
|
groups_seen[key] = {'key': key, 'label': s['year_month_label'], 'count': 0}
|
||||||
|
groups_seen[key]['count'] += 1
|
||||||
|
|
||||||
|
# Compute next cursor
|
||||||
|
next_cursor = None
|
||||||
|
if images and has_more:
|
||||||
|
last_img = images[-1]
|
||||||
|
last_dt = _get_image_date(last_img)
|
||||||
|
if last_dt:
|
||||||
|
next_cursor = last_dt.isoformat()
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
images=serialized,
|
||||||
|
next_cursor=next_cursor,
|
||||||
|
has_more=has_more,
|
||||||
|
date_groups=list(groups_seen.values())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@main.route('/api/gallery/timeline')
|
||||||
|
def gallery_timeline():
|
||||||
|
"""
|
||||||
|
Returns all year-month groups with counts for timeline navigation.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- tag: optional tag filter (same as gallery)
|
||||||
|
|
||||||
|
Returns list of {key, label, count, year} ordered newest to oldest.
|
||||||
|
"""
|
||||||
|
tag_name = request.args.get('tag')
|
||||||
|
|
||||||
|
# Use SQL to aggregate by year-month
|
||||||
|
date_expr = func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)
|
||||||
|
|
||||||
|
q = db.session.query(
|
||||||
|
extract('year', date_expr).label('year'),
|
||||||
|
extract('month', date_expr).label('month'),
|
||||||
|
func.count(ImageRecord.id).label('count')
|
||||||
|
)
|
||||||
|
|
||||||
|
if tag_name:
|
||||||
|
q = q.join(ImageRecord.tags).filter(Tag.name == tag_name)
|
||||||
|
|
||||||
|
q = q.group_by(
|
||||||
|
extract('year', date_expr),
|
||||||
|
extract('month', date_expr)
|
||||||
|
).order_by(
|
||||||
|
extract('year', date_expr).desc(),
|
||||||
|
extract('month', date_expr).desc()
|
||||||
|
)
|
||||||
|
|
||||||
|
results = q.all()
|
||||||
|
|
||||||
|
# Format results
|
||||||
|
groups = []
|
||||||
|
for row in results:
|
||||||
|
if row.year and row.month:
|
||||||
|
year = int(row.year)
|
||||||
|
month = int(row.month)
|
||||||
|
key = f"{year:04d}-{month:02d}"
|
||||||
|
# Create a date object to format the month name
|
||||||
|
dt = datetime(year, month, 1)
|
||||||
|
label = dt.strftime('%B %Y')
|
||||||
|
short_label = dt.strftime('%b') # Short month name for sidebar
|
||||||
|
groups.append({
|
||||||
|
'key': key,
|
||||||
|
'label': label,
|
||||||
|
'short_label': short_label,
|
||||||
|
'year': year,
|
||||||
|
'month': month,
|
||||||
|
'count': row.count
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(groups=groups)
|
||||||
|
|
||||||
|
|
||||||
|
@main.route('/api/gallery/jump')
|
||||||
|
def gallery_jump():
|
||||||
|
"""
|
||||||
|
Fetch images starting from a specific year-month for timeline jump navigation.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- year_month: target year-month (e.g., '2024-03')
|
||||||
|
- limit: number of images (default 30)
|
||||||
|
- tag: optional tag filter
|
||||||
|
|
||||||
|
Returns same format as /api/gallery/scroll but starting from the target month.
|
||||||
|
"""
|
||||||
|
year_month = request.args.get('year_month', '')
|
||||||
|
limit = request.args.get('limit', 30, type=int)
|
||||||
|
tag_name = request.args.get('tag')
|
||||||
|
|
||||||
|
if not year_month:
|
||||||
|
return jsonify(error='year_month is required'), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse year-month to get start of that month
|
||||||
|
target_dt = datetime.strptime(year_month + '-01', '%Y-%m-%d')
|
||||||
|
# Get end of month (start of next month)
|
||||||
|
if target_dt.month == 12:
|
||||||
|
next_month = datetime(target_dt.year + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
next_month = datetime(target_dt.year, target_dt.month + 1, 1)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify(error='Invalid year_month format'), 400
|
||||||
|
|
||||||
|
# Build query for images in that month and earlier
|
||||||
|
q = ImageRecord.query.options(joinedload(ImageRecord.tags))
|
||||||
|
|
||||||
|
if tag_name:
|
||||||
|
q = q.join(ImageRecord.tags).filter(Tag.name == tag_name)
|
||||||
|
|
||||||
|
# Get images from start of target month onwards (but ordered descending)
|
||||||
|
date_expr = func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)
|
||||||
|
q = q.filter(date_expr < next_month)
|
||||||
|
|
||||||
|
images = q.order_by(desc(date_expr)).limit(limit + 1).all()
|
||||||
|
|
||||||
|
has_more = len(images) > limit
|
||||||
|
if has_more:
|
||||||
|
images = images[:limit]
|
||||||
|
|
||||||
|
serialized = [_serialize_image(img) for img in images]
|
||||||
|
|
||||||
|
# Build date_groups summary
|
||||||
|
groups_seen = OrderedDict()
|
||||||
|
for s in serialized:
|
||||||
|
key = s['year_month']
|
||||||
|
if key not in groups_seen:
|
||||||
|
groups_seen[key] = {'key': key, 'label': s['year_month_label'], 'count': 0}
|
||||||
|
groups_seen[key]['count'] += 1
|
||||||
|
|
||||||
|
next_cursor = None
|
||||||
|
if images and has_more:
|
||||||
|
last_img = images[-1]
|
||||||
|
last_dt = _get_image_date(last_img)
|
||||||
|
if last_dt:
|
||||||
|
next_cursor = last_dt.isoformat()
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
images=serialized,
|
||||||
|
next_cursor=next_cursor,
|
||||||
|
has_more=has_more,
|
||||||
|
date_groups=list(groups_seen.values()),
|
||||||
|
target_year_month=year_month
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@main.route('/tags')
|
@main.route('/tags')
|
||||||
def tag_list():
|
def tag_list():
|
||||||
"""
|
"""
|
||||||
|
|||||||
+13
-1
@@ -23,7 +23,7 @@ class ImageRecord(db.Model):
|
|||||||
filepath = db.Column(db.String(512), nullable=False)
|
filepath = db.Column(db.String(512), nullable=False)
|
||||||
thumb_path = db.Column(db.String(512))
|
thumb_path = db.Column(db.String(512))
|
||||||
hash = db.Column(db.String(64), unique=True, nullable=False) # SHA256
|
hash = db.Column(db.String(64), unique=True, nullable=False) # SHA256
|
||||||
perceptual_hash = db.Column(db.String(16), nullable=True) # hex of 64-bit hash
|
perceptual_hash = db.Column(db.String(64), nullable=True) # hex of 256-bit hash
|
||||||
file_size = db.Column(db.Integer, nullable=True)
|
file_size = db.Column(db.Integer, nullable=True)
|
||||||
width = db.Column(db.Integer, nullable=True)
|
width = db.Column(db.Integer, nullable=True)
|
||||||
height = db.Column(db.Integer, nullable=True)
|
height = db.Column(db.Integer, nullable=True)
|
||||||
@@ -64,3 +64,15 @@ class ArchiveRecord(db.Model):
|
|||||||
artist = db.Column(db.String(255), nullable=True)
|
artist = db.Column(db.String(255), nullable=True)
|
||||||
tag_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="SET NULL"), nullable=True)
|
tag_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="SET NULL"), nullable=True)
|
||||||
tag = db.relationship("Tag")
|
tag = db.relationship("Tag")
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettings(db.Model):
|
||||||
|
"""
|
||||||
|
Key-value store for application settings and version tracking.
|
||||||
|
Used to persist configuration and trigger data migrations on version changes.
|
||||||
|
"""
|
||||||
|
__tablename__ = "app_settings"
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
key = db.Column(db.String(64), unique=True, nullable=False)
|
||||||
|
value = db.Column(db.Text, nullable=True)
|
||||||
|
updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now())
|
||||||
|
|||||||
@@ -0,0 +1,475 @@
|
|||||||
|
// /app/static/js/gallery-infinite.js
|
||||||
|
// Infinite scroll gallery with year-month date dividers and timeline navigation
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const CONFIG = {
|
||||||
|
SCROLL_THRESHOLD: 1200, // px from bottom to trigger load
|
||||||
|
BATCH_SIZE: 30, // images per API request
|
||||||
|
DEBOUNCE_SCROLL: 100, // ms debounce for scroll handler
|
||||||
|
DEBOUNCE_RESIZE: 200 // ms debounce for resize handler
|
||||||
|
};
|
||||||
|
|
||||||
|
// State
|
||||||
|
const state = {
|
||||||
|
isLoading: false,
|
||||||
|
hasMore: true,
|
||||||
|
cursor: null,
|
||||||
|
activeTag: null,
|
||||||
|
loadedSections: new Set(), // year_month keys of loaded sections
|
||||||
|
timelineData: [],
|
||||||
|
currentVisibleSection: null,
|
||||||
|
isJumping: false // true during jump navigation
|
||||||
|
};
|
||||||
|
|
||||||
|
// DOM references
|
||||||
|
const dom = {
|
||||||
|
container: null,
|
||||||
|
loading: null,
|
||||||
|
end: null,
|
||||||
|
timeline: null,
|
||||||
|
timelineTrack: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
function init() {
|
||||||
|
dom.container = document.getElementById('galleryInfinite');
|
||||||
|
dom.loading = document.getElementById('galleryLoading');
|
||||||
|
dom.end = document.getElementById('galleryEnd');
|
||||||
|
dom.timeline = document.getElementById('timelineSidebar');
|
||||||
|
dom.timelineTrack = document.getElementById('timelineTrack');
|
||||||
|
|
||||||
|
if (!dom.container) return;
|
||||||
|
|
||||||
|
// Parse initial state from window.galleryState (set by template)
|
||||||
|
if (window.galleryState) {
|
||||||
|
state.cursor = window.galleryState.cursor;
|
||||||
|
state.hasMore = window.galleryState.hasMore;
|
||||||
|
state.activeTag = window.galleryState.activeTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track initially loaded sections
|
||||||
|
parseInitialSections();
|
||||||
|
|
||||||
|
// Load timeline data
|
||||||
|
loadTimeline();
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
setupScrollListener();
|
||||||
|
setupIntersectionObserver();
|
||||||
|
|
||||||
|
// Check if we need to load more on init (short content)
|
||||||
|
setTimeout(checkScrollPosition, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse initially rendered sections
|
||||||
|
function parseInitialSections() {
|
||||||
|
const sections = dom.container.querySelectorAll('.date-section');
|
||||||
|
sections.forEach(section => {
|
||||||
|
const yearMonth = section.dataset.yearMonth;
|
||||||
|
if (yearMonth) {
|
||||||
|
state.loadedSections.add(yearMonth);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch more images
|
||||||
|
async function loadMore() {
|
||||||
|
if (state.isLoading || !state.hasMore) return;
|
||||||
|
|
||||||
|
state.isLoading = true;
|
||||||
|
dom.loading.style.display = 'flex';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: CONFIG.BATCH_SIZE
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.cursor) {
|
||||||
|
params.set('cursor', state.cursor);
|
||||||
|
}
|
||||||
|
if (state.activeTag) {
|
||||||
|
params.set('tag', state.activeTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/gallery/scroll?${params}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.images && data.images.length > 0) {
|
||||||
|
appendImages(data.images);
|
||||||
|
state.cursor = data.next_cursor;
|
||||||
|
state.hasMore = data.has_more;
|
||||||
|
|
||||||
|
// Notify modal system
|
||||||
|
dispatchGalleryUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.has_more) {
|
||||||
|
state.hasMore = false;
|
||||||
|
dom.end.style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load more images:', err);
|
||||||
|
} finally {
|
||||||
|
state.isLoading = false;
|
||||||
|
dom.loading.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append images to the gallery
|
||||||
|
function appendImages(images) {
|
||||||
|
// Group images by year_month
|
||||||
|
const groups = new Map();
|
||||||
|
|
||||||
|
images.forEach(img => {
|
||||||
|
const key = img.year_month;
|
||||||
|
if (!groups.has(key)) {
|
||||||
|
groups.set(key, {
|
||||||
|
key: key,
|
||||||
|
label: img.year_month_label,
|
||||||
|
images: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
groups.get(key).images.push(img);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Append to existing sections or create new ones
|
||||||
|
groups.forEach((group, key) => {
|
||||||
|
let section = dom.container.querySelector(`.date-section[data-year-month="${key}"]`);
|
||||||
|
|
||||||
|
if (section) {
|
||||||
|
// Append to existing section
|
||||||
|
const grid = section.querySelector('.gallery-grid');
|
||||||
|
group.images.forEach(img => {
|
||||||
|
grid.appendChild(createImageElement(img));
|
||||||
|
});
|
||||||
|
// Update count
|
||||||
|
updateSectionCount(section);
|
||||||
|
} else {
|
||||||
|
// Create new section
|
||||||
|
section = createDateSection(group.key, group.label, group.images);
|
||||||
|
dom.container.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.loadedSections.add(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update timeline highlights
|
||||||
|
updateTimelineLoaded();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a date section element
|
||||||
|
function createDateSection(yearMonth, label, images) {
|
||||||
|
const section = document.createElement('section');
|
||||||
|
section.className = 'date-section';
|
||||||
|
section.dataset.yearMonth = yearMonth;
|
||||||
|
|
||||||
|
const divider = document.createElement('h2');
|
||||||
|
divider.className = 'date-divider';
|
||||||
|
divider.id = `section-${yearMonth}`;
|
||||||
|
divider.innerHTML = `
|
||||||
|
<span class="date-divider-text">${label}</span>
|
||||||
|
<span class="date-divider-count">${images.length} images</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'gallery-grid';
|
||||||
|
|
||||||
|
images.forEach(img => {
|
||||||
|
grid.appendChild(createImageElement(img));
|
||||||
|
});
|
||||||
|
|
||||||
|
section.appendChild(divider);
|
||||||
|
section.appendChild(grid);
|
||||||
|
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a single image element
|
||||||
|
function createImageElement(img) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'gallery-item img-clickable';
|
||||||
|
item.dataset.id = img.id;
|
||||||
|
item.dataset.full = img.full_url;
|
||||||
|
item.dataset.type = img.is_video ? 'video' : 'image';
|
||||||
|
item.dataset.filename = img.filename;
|
||||||
|
|
||||||
|
// Build tag overlay HTML
|
||||||
|
let tagsHtml = '';
|
||||||
|
if (img.tags && img.tags.length > 0) {
|
||||||
|
const tagChips = img.tags.map(t => {
|
||||||
|
const displayName = t.name.includes(':') ? t.name.split(':')[1] : t.name;
|
||||||
|
const prefix = t.kind === 'user' ? '#' : '';
|
||||||
|
return `<a class="tag-chip" href="/gallery?tag=${encodeURIComponent(t.name)}" title="Filter by ${t.name}" onclick="event.stopPropagation()">${prefix}${displayName}</a>`;
|
||||||
|
}).join('');
|
||||||
|
tagsHtml = `<div class="tag-overlay">${tagChips}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play overlay for videos
|
||||||
|
const playOverlay = img.is_video ? '<div class="play-overlay"></div>' : '';
|
||||||
|
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="gallery-thumb" style="background-image: url('${img.thumb_url}');"></div>
|
||||||
|
${tagsHtml}
|
||||||
|
${playOverlay}
|
||||||
|
<span class="image-date">${img.date}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update section image count
|
||||||
|
function updateSectionCount(section) {
|
||||||
|
const grid = section.querySelector('.gallery-grid');
|
||||||
|
const count = grid.querySelectorAll('.gallery-item').length;
|
||||||
|
const countEl = section.querySelector('.date-divider-count');
|
||||||
|
if (countEl) {
|
||||||
|
countEl.textContent = `${count} images`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll handling
|
||||||
|
function setupScrollListener() {
|
||||||
|
let scrollTimeout = null;
|
||||||
|
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
if (scrollTimeout) return;
|
||||||
|
scrollTimeout = setTimeout(() => {
|
||||||
|
scrollTimeout = null;
|
||||||
|
checkScrollPosition();
|
||||||
|
}, CONFIG.DEBOUNCE_SCROLL);
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkScrollPosition() {
|
||||||
|
if (state.isLoading || !state.hasMore || state.isJumping) return;
|
||||||
|
|
||||||
|
const scrollBottom = window.innerHeight + window.scrollY;
|
||||||
|
const docHeight = document.documentElement.scrollHeight;
|
||||||
|
|
||||||
|
if (docHeight - scrollBottom < CONFIG.SCROLL_THRESHOLD) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intersection observer for tracking visible section
|
||||||
|
function setupIntersectionObserver() {
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const yearMonth = entry.target.dataset.yearMonth;
|
||||||
|
if (yearMonth && yearMonth !== state.currentVisibleSection) {
|
||||||
|
state.currentVisibleSection = yearMonth;
|
||||||
|
updateTimelineActive(yearMonth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
rootMargin: '-100px 0px -60% 0px',
|
||||||
|
threshold: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Observe all date sections
|
||||||
|
const observeSections = () => {
|
||||||
|
dom.container.querySelectorAll('.date-section').forEach(section => {
|
||||||
|
observer.observe(section);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
observeSections();
|
||||||
|
|
||||||
|
// Re-observe when new sections are added
|
||||||
|
const mutationObserver = new MutationObserver(() => {
|
||||||
|
observeSections();
|
||||||
|
});
|
||||||
|
mutationObserver.observe(dom.container, { childList: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline functions
|
||||||
|
async function loadTimeline() {
|
||||||
|
try {
|
||||||
|
const params = state.activeTag ? `?tag=${encodeURIComponent(state.activeTag)}` : '';
|
||||||
|
const response = await fetch(`/api/gallery/timeline${params}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.groups) {
|
||||||
|
state.timelineData = data.groups;
|
||||||
|
renderTimeline();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load timeline:', err);
|
||||||
|
dom.timelineTrack.innerHTML = '<div class="timeline-loading">Failed to load</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimeline() {
|
||||||
|
if (!dom.timelineTrack || state.timelineData.length === 0) {
|
||||||
|
dom.timelineTrack.innerHTML = '<div class="timeline-loading">No dates</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by year
|
||||||
|
const yearGroups = new Map();
|
||||||
|
state.timelineData.forEach(item => {
|
||||||
|
if (!yearGroups.has(item.year)) {
|
||||||
|
yearGroups.set(item.year, []);
|
||||||
|
}
|
||||||
|
yearGroups.get(item.year).push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
yearGroups.forEach((months, year) => {
|
||||||
|
html += `<div class="timeline-year-group" data-year="${year}">`;
|
||||||
|
html += `<div class="timeline-year">${year}</div>`;
|
||||||
|
html += '<div class="timeline-months">';
|
||||||
|
|
||||||
|
months.forEach(month => {
|
||||||
|
const isLoaded = state.loadedSections.has(month.key);
|
||||||
|
const loadedClass = isLoaded ? ' loaded' : '';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<a class="timeline-month${loadedClass}"
|
||||||
|
data-year-month="${month.key}"
|
||||||
|
href="#section-${month.key}"
|
||||||
|
title="${month.label}: ${month.count} images">
|
||||||
|
<span class="timeline-month-label">${month.short_label}</span>
|
||||||
|
<span class="timeline-month-count">${month.count}</span>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div></div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.timelineTrack.innerHTML = html;
|
||||||
|
|
||||||
|
// Add click handlers for timeline navigation
|
||||||
|
dom.timelineTrack.querySelectorAll('.timeline-month').forEach(el => {
|
||||||
|
el.addEventListener('click', handleTimelineClick);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update active state
|
||||||
|
if (state.currentVisibleSection) {
|
||||||
|
updateTimelineActive(state.currentVisibleSection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTimelineClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const yearMonth = e.currentTarget.dataset.yearMonth;
|
||||||
|
if (!yearMonth) return;
|
||||||
|
|
||||||
|
// Check if section is already loaded
|
||||||
|
const existingSection = document.getElementById(`section-${yearMonth}`);
|
||||||
|
|
||||||
|
if (existingSection) {
|
||||||
|
// Scroll to existing section
|
||||||
|
existingSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
} else {
|
||||||
|
// Jump to this date - fetch and load
|
||||||
|
jumpToDate(yearMonth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jumpToDate(yearMonth) {
|
||||||
|
if (state.isJumping) return;
|
||||||
|
|
||||||
|
state.isJumping = true;
|
||||||
|
dom.loading.style.display = 'flex';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
year_month: yearMonth,
|
||||||
|
limit: CONFIG.BATCH_SIZE
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.activeTag) {
|
||||||
|
params.set('tag', state.activeTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/gallery/jump?${params}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.images && data.images.length > 0) {
|
||||||
|
// Clear existing content and replace with jumped-to content
|
||||||
|
dom.container.innerHTML = '';
|
||||||
|
state.loadedSections.clear();
|
||||||
|
|
||||||
|
// Add images
|
||||||
|
appendImages(data.images);
|
||||||
|
|
||||||
|
// Update cursor for continued scrolling
|
||||||
|
state.cursor = data.next_cursor;
|
||||||
|
state.hasMore = data.has_more;
|
||||||
|
|
||||||
|
// Reset end marker
|
||||||
|
if (data.has_more) {
|
||||||
|
dom.end.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
dom.end.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to top of new content
|
||||||
|
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||||
|
|
||||||
|
// Update timeline
|
||||||
|
updateTimelineLoaded();
|
||||||
|
|
||||||
|
// Notify modal system
|
||||||
|
dispatchGalleryUpdate();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to jump to date:', err);
|
||||||
|
} finally {
|
||||||
|
state.isJumping = false;
|
||||||
|
dom.loading.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimelineActive(yearMonth) {
|
||||||
|
// Remove active from all
|
||||||
|
dom.timelineTrack.querySelectorAll('.timeline-month.active').forEach(el => {
|
||||||
|
el.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add active to current
|
||||||
|
const activeEl = dom.timelineTrack.querySelector(`.timeline-month[data-year-month="${yearMonth}"]`);
|
||||||
|
if (activeEl) {
|
||||||
|
activeEl.classList.add('active');
|
||||||
|
|
||||||
|
// Scroll timeline to keep active visible
|
||||||
|
const track = dom.timelineTrack;
|
||||||
|
const elRect = activeEl.getBoundingClientRect();
|
||||||
|
const trackRect = track.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (elRect.top < trackRect.top || elRect.bottom > trackRect.bottom) {
|
||||||
|
activeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimelineLoaded() {
|
||||||
|
dom.timelineTrack.querySelectorAll('.timeline-month').forEach(el => {
|
||||||
|
const yearMonth = el.dataset.yearMonth;
|
||||||
|
if (state.loadedSections.has(yearMonth)) {
|
||||||
|
el.classList.add('loaded');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch event for modal system integration
|
||||||
|
function dispatchGalleryUpdate() {
|
||||||
|
document.dispatchEvent(new CustomEvent('galleryUpdated'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on DOM ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -28,11 +28,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
let dragStartY = 0;
|
let dragStartY = 0;
|
||||||
let dragMoved = false;
|
let dragMoved = false;
|
||||||
|
|
||||||
|
// Legacy pagination inputs (for non-infinite-scroll pages)
|
||||||
const hasNextPageInput = document.getElementById('hasNextPage');
|
const hasNextPageInput = document.getElementById('hasNextPage');
|
||||||
const nextPageUrlInput = document.getElementById('nextPageUrl');
|
const nextPageUrlInput = document.getElementById('nextPageUrl');
|
||||||
const hasPrevPageInput = document.getElementById('hasPrevPage');
|
const hasPrevPageInput = document.getElementById('hasPrevPage');
|
||||||
const prevPageUrlInput = document.getElementById('prevPageUrl');
|
const prevPageUrlInput = document.getElementById('prevPageUrl');
|
||||||
|
|
||||||
|
// Check if we're in infinite scroll mode
|
||||||
|
// Infinite scroll mode is when galleryState exists (set by gallery.html template)
|
||||||
|
const isInfiniteScrollMode = typeof window.galleryState !== 'undefined';
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// Tag editor helpers
|
// Tag editor helpers
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
@@ -158,9 +163,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
async function fetchAutocomplete(query) {
|
async function fetchAutocomplete(query) {
|
||||||
try {
|
try {
|
||||||
|
// Exclude system-managed tag kinds from autocomplete (archive, post, source)
|
||||||
|
const excludeKinds = 'archive,post,source';
|
||||||
const url = query
|
const url = query
|
||||||
? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8&exclude_kind=archive`
|
? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8&exclude_kind=${excludeKinds}`
|
||||||
: `/api/tags/search?limit=8&exclude_kind=archive`;
|
: `/api/tags/search?limit=8&exclude_kind=${excludeKinds}`;
|
||||||
const r = await fetch(url);
|
const r = await fetch(url);
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
renderAutocomplete(j.tags || []);
|
renderAutocomplete(j.tags || []);
|
||||||
@@ -316,7 +323,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
function showNext() {
|
function showNext() {
|
||||||
if (currentIndex < images.length - 1) {
|
if (currentIndex < images.length - 1) {
|
||||||
updateImage(currentIndex + 1);
|
updateImage(currentIndex + 1);
|
||||||
|
} else if (isInfiniteScrollMode) {
|
||||||
|
// In infinite scroll mode, just stay at the last image
|
||||||
|
// User needs to scroll the gallery to load more
|
||||||
|
return;
|
||||||
} else if (hasNextPageInput?.value === "true") {
|
} else if (hasNextPageInput?.value === "true") {
|
||||||
|
// Legacy pagination mode - fetch next page
|
||||||
fetch(nextPageUrlInput.value)
|
fetch(nextPageUrlInput.value)
|
||||||
.then(res => res.text())
|
.then(res => res.text())
|
||||||
.then(html => {
|
.then(html => {
|
||||||
@@ -349,7 +361,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
function showPrev() {
|
function showPrev() {
|
||||||
if (currentIndex > 0) {
|
if (currentIndex > 0) {
|
||||||
updateImage(currentIndex - 1);
|
updateImage(currentIndex - 1);
|
||||||
|
} else if (isInfiniteScrollMode) {
|
||||||
|
// In infinite scroll mode, just stay at the first image
|
||||||
|
return;
|
||||||
} else if (hasPrevPageInput?.value === "true") {
|
} else if (hasPrevPageInput?.value === "true") {
|
||||||
|
// Legacy pagination mode - fetch previous page
|
||||||
fetch(prevPageUrlInput.value)
|
fetch(prevPageUrlInput.value)
|
||||||
.then(res => res.text())
|
.then(res => res.text())
|
||||||
.then(html => {
|
.then(html => {
|
||||||
@@ -480,6 +496,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
images = Array.from(document.querySelectorAll('.img-clickable'));
|
images = Array.from(document.querySelectorAll('.img-clickable'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for gallery infinite scroll updates to refresh the images array
|
||||||
|
document.addEventListener('galleryUpdated', () => {
|
||||||
|
images = Array.from(document.querySelectorAll('.img-clickable'));
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener('popstate', () => {
|
window.addEventListener('popstate', () => {
|
||||||
if (modal.classList.contains('active')) {
|
if (modal.classList.contains('active')) {
|
||||||
closeModal();
|
closeModal();
|
||||||
|
|||||||
@@ -97,10 +97,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function checkScrollPosition() {
|
function checkScrollPosition() {
|
||||||
const scrollBottom = window.innerHeight + window.scrollY;
|
// For masonry layouts, we need to check if the SHORTEST column is near the viewport bottom,
|
||||||
const docHeight = document.documentElement.scrollHeight;
|
// not the overall page bottom. Otherwise, a tall image in one column prevents loading
|
||||||
|
// even when other columns have empty space visible.
|
||||||
|
|
||||||
if (docHeight - scrollBottom < SCROLL_THRESHOLD) {
|
// First, update accurate column heights
|
||||||
|
updateColumnHeights();
|
||||||
|
|
||||||
|
// Find the shortest column's bottom position relative to viewport
|
||||||
|
const shortestColumnIndex = getShortestColumnIndex();
|
||||||
|
const shortestColumn = columns[shortestColumnIndex];
|
||||||
|
if (!shortestColumn) return;
|
||||||
|
|
||||||
|
const columnRect = shortestColumn.getBoundingClientRect();
|
||||||
|
const columnBottom = columnRect.bottom; // Distance from viewport top to column bottom
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
// If the shortest column's bottom is within SCROLL_THRESHOLD of the viewport bottom, load more
|
||||||
|
// columnBottom - viewportHeight = how far below the viewport the column extends
|
||||||
|
// If this is negative or small, the column end is visible or nearly visible
|
||||||
|
const distanceFromViewportBottom = columnBottom - viewportHeight;
|
||||||
|
|
||||||
|
if (distanceFromViewportBottom < SCROLL_THRESHOLD) {
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+278
-3
@@ -378,14 +378,25 @@ header {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
max-width: 100%;
|
width: 100%;
|
||||||
|
max-width: calc(100% - 300px); /* Account for tag editor */
|
||||||
|
height: calc(100vh - 140px);
|
||||||
max-height: calc(100vh - 140px);
|
max-height: calc(100vh - 140px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.modal-image-wrapper.zoomed { overflow: auto; cursor: grab; }
|
.modal-image-wrapper.zoomed {
|
||||||
|
overflow: auto;
|
||||||
|
cursor: grab;
|
||||||
|
/* Enable scrolling by removing flex centering when zoomed */
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
.modal-image-wrapper.zoomed img {
|
.modal-image-wrapper.zoomed img {
|
||||||
width: auto; height: auto; max-width: none; max-height: none;
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
cursor: grab;
|
||||||
}
|
}
|
||||||
.modal-image-wrapper img {
|
.modal-image-wrapper img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -404,6 +415,15 @@ header {
|
|||||||
width: 90vw; height: auto; max-height: none;
|
width: 90vw; height: auto; max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On smaller screens, full width for image wrapper */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.modal-image-wrapper {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Modal navigation buttons - cleaner pill style */
|
/* Modal navigation buttons - cleaner pill style */
|
||||||
.modal-button {
|
.modal-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1191,3 +1211,258 @@ select.form-input optgroup {
|
|||||||
overflow-wrap: anywhere; /* allows breaking long tokens */
|
overflow-wrap: anywhere; /* allows breaking long tokens */
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*------------------------------------------------------------------------------
|
||||||
|
Gallery Infinite Scroll Layout
|
||||||
|
------------------------------------------------------------------------------*/
|
||||||
|
.gallery-infinite-container {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: 1800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline Sidebar - positioned relative to gallery container */
|
||||||
|
.timeline-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 60px;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
width: 70px;
|
||||||
|
min-width: 70px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border-right: 1px solid rgba(255,255,255,0.08);
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255,255,255,0.2) transparent;
|
||||||
|
}
|
||||||
|
.timeline-sidebar::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
.timeline-sidebar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.timeline-sidebar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-track {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-loading {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-year-group {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-year {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.5rem 0.25rem 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: linear-gradient(to bottom, rgba(10,10,10,0.95), rgba(10,10,10,0.8));
|
||||||
|
z-index: 1;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-months {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-month {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.timeline-month:hover {
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.timeline-month.active {
|
||||||
|
background: var(--btn-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.timeline-month.loaded {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.timeline-month-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.timeline-month-count {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main gallery area - flex child next to timeline */
|
||||||
|
.gallery-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0 1rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.gallery-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.filter-tag {
|
||||||
|
color: var(--link);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.clear-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.clear-filter:hover {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gallery content area */
|
||||||
|
.gallery-infinite {
|
||||||
|
min-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date sections */
|
||||||
|
.date-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
|
position: sticky;
|
||||||
|
top: 50px;
|
||||||
|
background: linear-gradient(to bottom, var(--bg) 0%, var(--bg) 85%, transparent 100%);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-divider-text {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-divider-count {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading indicator */
|
||||||
|
.gallery-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.2);
|
||||||
|
border-top-color: var(--btn-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-end {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Play overlay with triangle icon */
|
||||||
|
.play-overlay::before {
|
||||||
|
content: '';
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 20px 0 20px 35px;
|
||||||
|
border-color: transparent transparent transparent rgba(255,255,255,0.9);
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: hide timeline sidebar */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.timeline-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.date-divider {
|
||||||
|
top: 45px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!-- /app/templates/_gallery_item.html -->
|
||||||
|
<div class="gallery-item img-clickable"
|
||||||
|
data-id="{{ image.id }}"
|
||||||
|
data-full="{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '') ) }}"
|
||||||
|
data-type="{{ 'video' if image.filename.lower().endswith(('.mp4', '.mov')) else 'image' }}"
|
||||||
|
data-filename="{{ image.filename }}">
|
||||||
|
|
||||||
|
<div class="gallery-thumb"
|
||||||
|
style="background-image: url('{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}');"></div>
|
||||||
|
|
||||||
|
{% set visible_tags = image.tags | selectattr('kind', 'ne', 'archive') | list %}
|
||||||
|
{% if visible_tags %}
|
||||||
|
<div class="tag-overlay">
|
||||||
|
{% for t in visible_tags %}
|
||||||
|
<a class="tag-chip"
|
||||||
|
href="{{ url_for('main.gallery', tag=t.name) }}"
|
||||||
|
title="Filter by {{ t.name }}"
|
||||||
|
onclick="event.stopPropagation()">
|
||||||
|
{% if t.kind == 'artist' %}
|
||||||
|
{{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
|
||||||
|
{% elif t.kind == 'character' %}
|
||||||
|
{{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
|
||||||
|
{% elif t.kind == 'series' %}
|
||||||
|
{{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
|
||||||
|
{% elif t.kind == 'rating' %}
|
||||||
|
{{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
|
||||||
|
{% else %}
|
||||||
|
#{{ t.name }}
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if image.filename.lower().endswith(('.mp4', '.mov')) %}
|
||||||
|
<div class="play-overlay"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="image-date">{{ image.taken_at or image.imported_at | datetimeformat }}</span>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<!-- /app/templates/_gallery_modal.html -->
|
||||||
|
<div id="imageModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<button id="modalClose" class="modal-close-btn" title="Close (ESC)">×</button>
|
||||||
|
<button id="modalPrev" class="modal-button modal-prev">‹</button>
|
||||||
|
<button id="modalNext" class="modal-button modal-next">›</button>
|
||||||
|
<span class="modal-close-hint">ESC to close</span>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="modalImageWrapper" class="modal-image-wrapper">
|
||||||
|
<img id="modalImage" src="" alt="Full View">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modalTagEditor" class="tag-editor" data-image-id="">
|
||||||
|
<div id="modalTagList" class="tags"></div>
|
||||||
|
<form id="modalTagForm" class="tag-form" autocomplete="off">
|
||||||
|
<div class="tag-form-wrapper">
|
||||||
|
<input type="text" id="tagInput" name="name" placeholder="Add tag..." autocomplete="off">
|
||||||
|
<div id="tagAutocomplete" class="tag-autocomplete"></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -3,14 +3,73 @@
|
|||||||
{% block title %}Gallery{% endblock %}
|
{% block title %}Gallery{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 style="text-align:center; margin-top: 1rem;">Gallery</h1>
|
<div class="gallery-infinite-container">
|
||||||
|
<!-- Timeline Scrollbar (left sidebar) -->
|
||||||
|
<aside id="timelineSidebar" class="timeline-sidebar">
|
||||||
|
<div id="timelineTrack" class="timeline-track">
|
||||||
|
<!-- Populated by JS with year-month markers -->
|
||||||
|
<div class="timeline-loading">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<!-- Gallery Grid -->
|
<!-- Main Gallery Content -->
|
||||||
{% include '_gallery_grid.html' %}
|
<main class="gallery-main">
|
||||||
|
<div class="gallery-header">
|
||||||
|
<h1>Gallery</h1>
|
||||||
|
{% if active_tag %}
|
||||||
|
<div class="active-filter">
|
||||||
|
Filtered by: <span class="filter-tag">{{ active_tag }}</span>
|
||||||
|
<a href="{{ url_for('main.gallery') }}" class="clear-filter" title="Clear filter">×</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Floating Pagination Bar -->
|
<!-- Gallery Grid with Date Sections -->
|
||||||
{% if images.pages > 1 %}
|
<div id="galleryInfinite" class="gallery-infinite">
|
||||||
{% include '_pagination_floating.html' %}
|
{% for year_month_key, year_month_label, images in grouped_images %}
|
||||||
{% endif %}
|
<section class="date-section" data-year-month="{{ year_month_key }}">
|
||||||
|
<h2 class="date-divider" id="section-{{ year_month_key }}">
|
||||||
|
<span class="date-divider-text">{{ year_month_label }}</span>
|
||||||
|
<span class="date-divider-count">{{ images|length }} images</span>
|
||||||
|
</h2>
|
||||||
|
<div class="gallery-grid">
|
||||||
|
{% for image in images %}
|
||||||
|
{% include '_gallery_item.html' %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<div class="gallery-empty">
|
||||||
|
<p>No images found{% if active_tag %} for tag "{{ active_tag }}"{% endif %}.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<div id="galleryLoading" class="gallery-loading" style="display:none;">
|
||||||
|
<span class="loading-spinner"></span>
|
||||||
|
<span>Loading more images...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- End of content marker -->
|
||||||
|
<div id="galleryEnd" class="gallery-end" style="display:none;">
|
||||||
|
No more images
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
{% include '_gallery_modal.html' %}
|
||||||
|
|
||||||
|
<!-- Pass initial state to JS -->
|
||||||
|
<script>
|
||||||
|
window.galleryState = {
|
||||||
|
cursor: {{ initial_cursor | tojson | safe if initial_cursor else 'null' }},
|
||||||
|
hasMore: {{ 'true' if has_more else 'false' }},
|
||||||
|
activeTag: {{ active_tag | tojson | safe if active_tag else 'null' }}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/gallery-infinite.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modal-pagination.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -37,12 +37,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if request.endpoint == 'main.gallery' %}
|
|
||||||
<input type="hidden" id="hasNextPage" value="{{ has_next|lower }}">
|
|
||||||
<input type="hidden" id="nextPageUrl" value="{{ next_page_url }}">
|
|
||||||
<input type="hidden" id="hasPrevPage" value="{{ has_prev|lower }}">
|
|
||||||
<input type="hidden" id="prevPageUrl" value="{{ prev_page_url }}">
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+429
-37
@@ -50,21 +50,43 @@ IMPORT_STATS_PATH = "/import/stats.json"
|
|||||||
|
|
||||||
|
|
||||||
def load_import_settings() -> dict:
|
def load_import_settings() -> dict:
|
||||||
"""Load import filter settings from file or environment."""
|
"""
|
||||||
|
Load import filter settings from database, with file and environment fallbacks.
|
||||||
|
Priority: DB settings > file settings > environment variables
|
||||||
|
"""
|
||||||
|
# Start with environment defaults
|
||||||
defaults = {
|
defaults = {
|
||||||
"min_width": int(os.environ.get("IMPORT_MIN_WIDTH", "0")),
|
"min_width": int(os.environ.get("IMPORT_MIN_WIDTH", "0")),
|
||||||
"min_height": int(os.environ.get("IMPORT_MIN_HEIGHT", "0")),
|
"min_height": int(os.environ.get("IMPORT_MIN_HEIGHT", "0")),
|
||||||
"skip_transparent": os.environ.get("IMPORT_SKIP_TRANSPARENT", "false").lower() == "true",
|
"skip_transparent": os.environ.get("IMPORT_SKIP_TRANSPARENT", "false").lower() == "true",
|
||||||
"transparency_threshold": float(os.environ.get("IMPORT_TRANSPARENCY_THRESHOLD", "0.9")),
|
"transparency_threshold": float(os.environ.get("IMPORT_TRANSPARENCY_THRESHOLD", "0.9")),
|
||||||
"phash_threshold": int(os.environ.get("IMPORT_PHASH_THRESHOLD", "2")),
|
"skip_single_color": os.environ.get("IMPORT_SKIP_SINGLE_COLOR", "true").lower() == "true",
|
||||||
|
"single_color_threshold": float(os.environ.get("IMPORT_SINGLE_COLOR_THRESHOLD", "0.95")),
|
||||||
|
"single_color_tolerance": int(os.environ.get("IMPORT_SINGLE_COLOR_TOLERANCE", "30")),
|
||||||
|
"phash_threshold": int(os.environ.get("IMPORT_PHASH_THRESHOLD", "10")), # Default for 256-bit
|
||||||
|
"supersede_smaller": os.environ.get("IMPORT_SUPERSEDE_SMALLER", "true").lower() == "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Try to load from file (legacy support)
|
||||||
try:
|
try:
|
||||||
if os.path.exists(IMPORT_SETTINGS_PATH):
|
if os.path.exists(IMPORT_SETTINGS_PATH):
|
||||||
with open(IMPORT_SETTINGS_PATH, "r") as f:
|
with open(IMPORT_SETTINGS_PATH, "r") as f:
|
||||||
file_settings = json.load(f)
|
file_settings = json.load(f)
|
||||||
defaults.update(file_settings)
|
defaults.update(file_settings)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WARN] Failed to load import settings: {e}")
|
print(f"[WARN] Failed to load import settings from file: {e}")
|
||||||
|
|
||||||
|
# Try to load from database (preferred)
|
||||||
|
try:
|
||||||
|
from app.utils.version_migration import get_import_settings
|
||||||
|
db_settings = get_import_settings()
|
||||||
|
# Only override if DB has non-default values
|
||||||
|
for key, value in db_settings.items():
|
||||||
|
if value is not None:
|
||||||
|
defaults[key] = value
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Failed to load import settings from DB: {e}")
|
||||||
|
|
||||||
return defaults
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
@@ -106,6 +128,136 @@ def is_mostly_transparent(file_path: str, threshold: float = 0.9) -> bool:
|
|||||||
print(f"[WARN] Failed to check transparency for {file_path}: {e}")
|
print(f"[WARN] Failed to check transparency for {file_path}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_mostly_single_color(file_path: str, threshold: float = 0.95, tolerance: int = 30) -> bool:
|
||||||
|
"""
|
||||||
|
Check if an image is mostly a single color (> threshold % of pixels are similar).
|
||||||
|
Uses color distance tolerance to group similar colors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the image file
|
||||||
|
threshold: Percentage of pixels that must be similar (0.0-1.0, default 0.95 = 95%)
|
||||||
|
tolerance: Maximum color distance to be considered "same" color (0-255 per channel, default 30)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the image is mostly a single solid color
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with Image.open(file_path) as img:
|
||||||
|
# Convert to RGB for consistent color comparison
|
||||||
|
if img.mode in ("RGBA", "LA"):
|
||||||
|
# For images with alpha, only consider non-transparent pixels
|
||||||
|
img_rgba = img.convert("RGBA") if img.mode != "RGBA" else img
|
||||||
|
pixels = list(img_rgba.getdata())
|
||||||
|
# Filter out mostly transparent pixels (alpha < 128)
|
||||||
|
opaque_pixels = [(r, g, b) for r, g, b, a in pixels if a >= 128]
|
||||||
|
if len(opaque_pixels) == 0:
|
||||||
|
return True # Fully transparent = effectively single color
|
||||||
|
pixels = opaque_pixels
|
||||||
|
elif img.mode == "P":
|
||||||
|
img = img.convert("RGB")
|
||||||
|
pixels = list(img.getdata())
|
||||||
|
elif img.mode == "L":
|
||||||
|
# Grayscale - convert to RGB tuples
|
||||||
|
pixels = [(p, p, p) for p in img.getdata()]
|
||||||
|
else:
|
||||||
|
if img.mode != "RGB":
|
||||||
|
img = img.convert("RGB")
|
||||||
|
pixels = list(img.getdata())
|
||||||
|
|
||||||
|
if len(pixels) == 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Sample pixels for performance on large images
|
||||||
|
total_pixels = len(pixels)
|
||||||
|
if total_pixels > 10000:
|
||||||
|
# Sample evenly distributed pixels
|
||||||
|
step = total_pixels // 10000
|
||||||
|
pixels = pixels[::step]
|
||||||
|
|
||||||
|
# Find the dominant color (most common pixel or average of sampled area)
|
||||||
|
# Use the pixel at center as reference
|
||||||
|
center_idx = len(pixels) // 2
|
||||||
|
ref_color = pixels[center_idx]
|
||||||
|
|
||||||
|
# Count pixels within tolerance of reference color
|
||||||
|
similar_count = 0
|
||||||
|
for pixel in pixels:
|
||||||
|
if isinstance(pixel, int):
|
||||||
|
pixel = (pixel, pixel, pixel)
|
||||||
|
# Calculate color distance (simple RGB distance)
|
||||||
|
dist = abs(pixel[0] - ref_color[0]) + abs(pixel[1] - ref_color[1]) + abs(pixel[2] - ref_color[2])
|
||||||
|
if dist <= tolerance * 3: # tolerance per channel * 3 channels
|
||||||
|
similar_count += 1
|
||||||
|
|
||||||
|
similarity_ratio = similar_count / len(pixels)
|
||||||
|
return similarity_ratio > threshold
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Failed to check single color for {file_path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def supersede_image(old_record: "ImageRecord", new_filepath: str, new_thumb_path: str,
|
||||||
|
new_hash: str, new_phash, new_metadata: dict, new_filename: str) -> "ImageRecord":
|
||||||
|
"""
|
||||||
|
Replace a smaller image with a larger version, preserving tags and metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_record: The existing ImageRecord to supersede
|
||||||
|
new_filepath: Path to the new larger image file
|
||||||
|
new_thumb_path: Path to the new thumbnail
|
||||||
|
new_hash: SHA256 hash of the new file
|
||||||
|
new_phash: Perceptual hash of the new image
|
||||||
|
new_metadata: Metadata dict for the new image
|
||||||
|
new_filename: Original filename of the new image
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated ImageRecord
|
||||||
|
"""
|
||||||
|
# Store old file paths for cleanup
|
||||||
|
old_filepath = old_record.filepath
|
||||||
|
old_thumb_path = old_record.thumb_path
|
||||||
|
|
||||||
|
print(f"[SUPERSEDE] Replacing {old_record.filename} ({old_record.width}x{old_record.height}) "
|
||||||
|
f"with {new_filename} ({new_metadata['width']}x{new_metadata['height']})")
|
||||||
|
|
||||||
|
# Update record with new file info
|
||||||
|
old_record.filename = new_filename
|
||||||
|
old_record.filepath = new_filepath
|
||||||
|
old_record.thumb_path = new_thumb_path
|
||||||
|
old_record.hash = new_hash
|
||||||
|
old_record.perceptual_hash = str(new_phash) if new_phash else None
|
||||||
|
old_record.file_size = new_metadata["file_size"]
|
||||||
|
old_record.width = new_metadata["width"]
|
||||||
|
old_record.height = new_metadata["height"]
|
||||||
|
old_record.format = new_metadata["format"]
|
||||||
|
# Keep the earlier taken_at date (prefer original date)
|
||||||
|
if new_metadata.get("taken_at") and old_record.taken_at:
|
||||||
|
if new_metadata["taken_at"] < old_record.taken_at:
|
||||||
|
old_record.taken_at = new_metadata["taken_at"]
|
||||||
|
elif new_metadata.get("taken_at") and not old_record.taken_at:
|
||||||
|
old_record.taken_at = new_metadata["taken_at"]
|
||||||
|
# Don't update imported_at - keep original import time
|
||||||
|
|
||||||
|
# Delete old files
|
||||||
|
try:
|
||||||
|
if old_filepath and os.path.exists(old_filepath):
|
||||||
|
os.remove(old_filepath)
|
||||||
|
print(f"[SUPERSEDE] Deleted old file: {old_filepath}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Failed to delete old file {old_filepath}: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if old_thumb_path and os.path.exists(old_thumb_path):
|
||||||
|
os.remove(old_thumb_path)
|
||||||
|
print(f"[SUPERSEDE] Deleted old thumbnail: {old_thumb_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Failed to delete old thumbnail {old_thumb_path}: {e}")
|
||||||
|
|
||||||
|
return old_record
|
||||||
|
|
||||||
|
|
||||||
# Regex for multi-part detection
|
# Regex for multi-part detection
|
||||||
RAR_PART_RE = re.compile(r"\.part(\d+)\.rar$", re.IGNORECASE)
|
RAR_PART_RE = re.compile(r"\.part(\d+)\.rar$", re.IGNORECASE)
|
||||||
SEVENZ_PART_RE = re.compile(r"\.7z\.(\d{3})$", re.IGNORECASE)
|
SEVENZ_PART_RE = re.compile(r"\.7z\.(\d{3})$", re.IGNORECASE)
|
||||||
@@ -233,19 +385,25 @@ def import_images_task(source_dir: str, dest_dir: str) -> str:
|
|||||||
min_height = settings.get("min_height", 0)
|
min_height = settings.get("min_height", 0)
|
||||||
skip_transparent = settings.get("skip_transparent", False)
|
skip_transparent = settings.get("skip_transparent", False)
|
||||||
transparency_threshold = settings.get("transparency_threshold", 0.9)
|
transparency_threshold = settings.get("transparency_threshold", 0.9)
|
||||||
|
skip_single_color = settings.get("skip_single_color", True)
|
||||||
|
single_color_threshold = settings.get("single_color_threshold", 0.95)
|
||||||
|
single_color_tolerance = settings.get("single_color_tolerance", 30)
|
||||||
phash_threshold = settings.get("phash_threshold", 2)
|
phash_threshold = settings.get("phash_threshold", 2)
|
||||||
|
supersede_smaller = settings.get("supersede_smaller", True)
|
||||||
|
|
||||||
print(f"[INFO] Import settings: min_width={min_width}, min_height={min_height}, "
|
print(f"[INFO] Import settings: min_width={min_width}, min_height={min_height}, "
|
||||||
f"skip_transparent={skip_transparent}, transparency_threshold={transparency_threshold}, "
|
f"skip_transparent={skip_transparent}, transparency_threshold={transparency_threshold}, "
|
||||||
f"phash_threshold={phash_threshold}")
|
f"skip_single_color={skip_single_color}, single_color_threshold={single_color_threshold}, "
|
||||||
|
f"phash_threshold={phash_threshold}, supersede_smaller={supersede_smaller}")
|
||||||
|
|
||||||
# Statistics tracking
|
# Statistics tracking
|
||||||
stats = {
|
stats = {
|
||||||
"total_processed": 0,
|
"total_processed": 0,
|
||||||
"total_imported": 0,
|
"total_imported": 0,
|
||||||
|
"total_superseded": 0,
|
||||||
"filtered_by_dimension": 0,
|
"filtered_by_dimension": 0,
|
||||||
"filtered_by_transparency": 0,
|
"filtered_by_transparency": 0,
|
||||||
|
"filtered_by_single_color": 0,
|
||||||
"filtered_by_duplicate": 0,
|
"filtered_by_duplicate": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,8 +412,9 @@ def import_images_task(source_dir: str, dest_dir: str) -> str:
|
|||||||
batch_counter = 0
|
batch_counter = 0
|
||||||
|
|
||||||
# Preload existing pHashes to avoid N+1 lookups
|
# Preload existing pHashes to avoid N+1 lookups
|
||||||
|
# Format: (phash, width, height, image_id) for supersede functionality
|
||||||
existing_phashes = [
|
existing_phashes = [
|
||||||
(imagehash.hex_to_hash(img.perceptual_hash), img.width, img.height)
|
(imagehash.hex_to_hash(img.perceptual_hash), img.width, img.height, img.id)
|
||||||
for img in ImageRecord.query.filter(ImageRecord.perceptual_hash != None).all()
|
for img in ImageRecord.query.filter(ImageRecord.perceptual_hash != None).all()
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -320,24 +479,34 @@ def import_images_task(source_dir: str, dest_dir: str) -> str:
|
|||||||
stats["filtered_by_transparency"] += 1
|
stats["filtered_by_transparency"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
added, phash, _rec = import_single_file(
|
# Apply single-color filter (skip images that are mostly one solid color)
|
||||||
|
if skip_single_color and lower.endswith((".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")):
|
||||||
|
if is_mostly_single_color(full_path, single_color_threshold, single_color_tolerance):
|
||||||
|
print(f"[SKIP] Mostly single color: {file}")
|
||||||
|
stats["filtered_by_single_color"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
added, phash, _rec, superseded = import_single_file(
|
||||||
src_path=full_path,
|
src_path=full_path,
|
||||||
dest_dir=dest_dir,
|
dest_dir=dest_dir,
|
||||||
artist=artist_dir,
|
artist=artist_dir,
|
||||||
existing_phashes=existing_phashes,
|
existing_phashes=existing_phashes,
|
||||||
phash_threshold=phash_threshold
|
phash_threshold=phash_threshold,
|
||||||
|
supersede_smaller=supersede_smaller
|
||||||
)
|
)
|
||||||
if added:
|
if added:
|
||||||
imported.append(file)
|
imported.append(file)
|
||||||
stats["total_imported"] += 1
|
if superseded:
|
||||||
|
stats["total_superseded"] += 1
|
||||||
|
else:
|
||||||
|
stats["total_imported"] += 1
|
||||||
elif _rec is None and phash is None:
|
elif _rec is None and phash is None:
|
||||||
# Was a duplicate
|
# Was a duplicate
|
||||||
stats["filtered_by_duplicate"] += 1
|
stats["filtered_by_duplicate"] += 1
|
||||||
|
|
||||||
if phash:
|
# Add new image's phash to the tracking list (for fresh imports only, not supersedes)
|
||||||
md = extract_metadata(full_path)
|
if phash and _rec and not superseded:
|
||||||
if md.get("width") and md.get("height"):
|
existing_phashes.append((phash, _rec.width, _rec.height, _rec.id))
|
||||||
existing_phashes.append((phash, md["width"], md["height"]))
|
|
||||||
|
|
||||||
batch_counter += 1
|
batch_counter += 1
|
||||||
if batch_counter >= batch_size:
|
if batch_counter >= batch_size:
|
||||||
@@ -354,7 +523,9 @@ def import_images_task(source_dir: str, dest_dir: str) -> str:
|
|||||||
# Save stats
|
# Save stats
|
||||||
save_import_stats(stats)
|
save_import_stats(stats)
|
||||||
|
|
||||||
summary = f"Imported {len(imported)} items. Filtered: {stats['filtered_by_dimension']} by size, {stats['filtered_by_transparency']} by transparency."
|
summary = (f"Imported {stats['total_imported']} items, superseded {stats['total_superseded']} smaller versions. "
|
||||||
|
f"Filtered: {stats['filtered_by_dimension']} by size, {stats['filtered_by_transparency']} by transparency, "
|
||||||
|
f"{stats['filtered_by_single_color']} by single color, {stats['filtered_by_duplicate']} duplicates.")
|
||||||
print(f"[INFO] {summary}")
|
print(f"[INFO] {summary}")
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
@@ -368,14 +539,19 @@ def process_archive(archive_path, dest_dir, artist, existing_phashes, settings=N
|
|||||||
if settings is None:
|
if settings is None:
|
||||||
settings = load_import_settings()
|
settings = load_import_settings()
|
||||||
if stats is None:
|
if stats is None:
|
||||||
stats = {"total_processed": 0, "total_imported": 0, "filtered_by_dimension": 0,
|
stats = {"total_processed": 0, "total_imported": 0, "total_superseded": 0,
|
||||||
"filtered_by_transparency": 0, "filtered_by_duplicate": 0}
|
"filtered_by_dimension": 0, "filtered_by_transparency": 0,
|
||||||
|
"filtered_by_single_color": 0, "filtered_by_duplicate": 0}
|
||||||
|
|
||||||
min_width = settings.get("min_width", 0)
|
min_width = settings.get("min_width", 0)
|
||||||
min_height = settings.get("min_height", 0)
|
min_height = settings.get("min_height", 0)
|
||||||
skip_transparent = settings.get("skip_transparent", False)
|
skip_transparent = settings.get("skip_transparent", False)
|
||||||
transparency_threshold = settings.get("transparency_threshold", 0.9)
|
transparency_threshold = settings.get("transparency_threshold", 0.9)
|
||||||
|
skip_single_color = settings.get("skip_single_color", True)
|
||||||
|
single_color_threshold = settings.get("single_color_threshold", 0.95)
|
||||||
|
single_color_tolerance = settings.get("single_color_tolerance", 30)
|
||||||
phash_threshold = settings.get("phash_threshold", 2)
|
phash_threshold = settings.get("phash_threshold", 2)
|
||||||
|
supersede_smaller = settings.get("supersede_smaller", True)
|
||||||
|
|
||||||
archive_size = os.path.getsize(archive_path)
|
archive_size = os.path.getsize(archive_path)
|
||||||
archive_hash = calculate_hash(archive_path)
|
archive_hash = calculate_hash(archive_path)
|
||||||
@@ -420,19 +596,30 @@ def process_archive(archive_path, dest_dir, artist, existing_phashes, settings=N
|
|||||||
stats["filtered_by_transparency"] += 1
|
stats["filtered_by_transparency"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
added, _phash, rec = import_single_file(
|
# Apply single-color filter
|
||||||
|
if skip_single_color and lower.endswith((".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")):
|
||||||
|
if is_mostly_single_color(src_path, single_color_threshold, single_color_tolerance):
|
||||||
|
print(f"[SKIP] Mostly single color: {file}")
|
||||||
|
stats["filtered_by_single_color"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
added, _phash, rec, superseded = import_single_file(
|
||||||
src_path=src_path,
|
src_path=src_path,
|
||||||
dest_dir=dest_dir,
|
dest_dir=dest_dir,
|
||||||
artist=artist, # still used for auto artist:<name> tag
|
artist=artist, # still used for auto artist:<name> tag
|
||||||
existing_phashes=existing_phashes,
|
existing_phashes=existing_phashes,
|
||||||
commit=False,
|
commit=False,
|
||||||
phash_threshold=phash_threshold
|
phash_threshold=phash_threshold,
|
||||||
|
supersede_smaller=supersede_smaller
|
||||||
)
|
)
|
||||||
if rec is not None:
|
if rec is not None:
|
||||||
touched_records.append(rec)
|
touched_records.append(rec)
|
||||||
if added:
|
if added:
|
||||||
imported_or_tagged += 1
|
imported_or_tagged += 1
|
||||||
stats["total_imported"] += 1
|
if superseded:
|
||||||
|
stats["total_superseded"] += 1
|
||||||
|
else:
|
||||||
|
stats["total_imported"] += 1
|
||||||
elif rec is None and _phash is None:
|
elif rec is None and _phash is None:
|
||||||
stats["filtered_by_duplicate"] += 1
|
stats["filtered_by_duplicate"] += 1
|
||||||
|
|
||||||
@@ -495,11 +682,19 @@ def build_hashed_dest_path(target_dir: str, original_name: str, content_hash: st
|
|||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
|
|
||||||
def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phashes, commit: bool = True, phash_threshold: int = 2):
|
def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phashes,
|
||||||
|
commit: bool = True, phash_threshold: int = 2, supersede_smaller: bool = True):
|
||||||
"""
|
"""
|
||||||
Import a single media file. If an equivalent file already exists, return that
|
Import a single media file. If an equivalent file already exists, return that
|
||||||
record so callers can tag it (no duplicate import).
|
record so callers can tag it (no duplicate import).
|
||||||
Returns (added: bool, phash_or_None, record_or_None).
|
|
||||||
|
If a smaller visually-similar image exists and supersede_smaller is True,
|
||||||
|
the smaller image will be replaced with the larger one (preserving tags).
|
||||||
|
|
||||||
|
Also enriches metadata from Gallery-DL sidecar JSON files if present.
|
||||||
|
|
||||||
|
Returns (added: bool, phash_or_None, record_or_None, superseded: bool).
|
||||||
|
The superseded flag indicates if an existing smaller image was replaced.
|
||||||
"""
|
"""
|
||||||
original_name = os.path.basename(src_path)
|
original_name = os.path.basename(src_path)
|
||||||
print(f"[INFO] Processing: {src_path}")
|
print(f"[INFO] Processing: {src_path}")
|
||||||
@@ -510,7 +705,9 @@ def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phash
|
|||||||
existing = ImageRecord.query.filter_by(hash=file_hash).first()
|
existing = ImageRecord.query.filter_by(hash=file_hash).first()
|
||||||
if existing:
|
if existing:
|
||||||
print(f"[SKIP] Duplicate by hash: {original_name}")
|
print(f"[SKIP] Duplicate by hash: {original_name}")
|
||||||
return (False, None, existing)
|
# Even for duplicates, try to enrich with sidecar metadata
|
||||||
|
_enrich_existing_record(existing, src_path)
|
||||||
|
return (False, None, existing, False)
|
||||||
|
|
||||||
# Optional info: same name+size (but different content) → we still import with hashed name
|
# Optional info: same name+size (but different content) → we still import with hashed name
|
||||||
existing_ns = ImageRecord.query.filter_by(filename=original_name, file_size=file_size).first()
|
existing_ns = ImageRecord.query.filter_by(filename=original_name, file_size=file_size).first()
|
||||||
@@ -521,12 +718,67 @@ def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phash
|
|||||||
metadata = extract_metadata(src_path)
|
metadata = extract_metadata(src_path)
|
||||||
if not metadata["width"] or not metadata["height"]:
|
if not metadata["width"] or not metadata["height"]:
|
||||||
print(f"[SKIP] Missing dimension data for {original_name}")
|
print(f"[SKIP] Missing dimension data for {original_name}")
|
||||||
return (False, None, None)
|
return (False, None, None, False)
|
||||||
|
|
||||||
phash = calculate_perceptual_hash(src_path)
|
phash = calculate_perceptual_hash(src_path)
|
||||||
if phash and phash_threshold > 0 and is_similar_image(phash, metadata["width"], metadata["height"], existing_phashes, threshold=phash_threshold):
|
|
||||||
print(f"[SKIP] {original_name} is visually similar to an existing larger image (threshold={phash_threshold}).")
|
# Check for similar images
|
||||||
return (False, phash, None)
|
superseded = False
|
||||||
|
if phash and phash_threshold > 0:
|
||||||
|
relationship, similar_id = find_similar_image(
|
||||||
|
phash, metadata["width"], metadata["height"], existing_phashes, threshold=phash_threshold
|
||||||
|
)
|
||||||
|
|
||||||
|
if relationship == "larger_exists":
|
||||||
|
print(f"[SKIP] {original_name} is visually similar to an existing larger image (threshold={phash_threshold}).")
|
||||||
|
return (False, phash, None, False)
|
||||||
|
|
||||||
|
elif relationship == "smaller_exists" and supersede_smaller and similar_id:
|
||||||
|
# Found a smaller version - supersede it
|
||||||
|
old_record = ImageRecord.query.get(similar_id)
|
||||||
|
if old_record:
|
||||||
|
# Enrich metadata from Gallery-DL sidecar JSON if present
|
||||||
|
enriched = _get_enriched_metadata(src_path, metadata)
|
||||||
|
metadata["taken_at"] = enriched.get("taken_at") or metadata["taken_at"]
|
||||||
|
|
||||||
|
# Copy new file to destination
|
||||||
|
target_dir = os.path.join(dest_dir, artist)
|
||||||
|
os.makedirs(target_dir, exist_ok=True)
|
||||||
|
dest_path = build_hashed_dest_path(target_dir, original_name, file_hash)
|
||||||
|
shutil.copy2(src_path, dest_path)
|
||||||
|
|
||||||
|
# Generate new thumbnail
|
||||||
|
try:
|
||||||
|
if dest_path.lower().endswith((".mp4", ".mov")):
|
||||||
|
thumb_path = generate_video_thumbnail_mirrored(dest_path)
|
||||||
|
else:
|
||||||
|
thumb_path = generate_thumbnail(dest_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Failed to generate thumbnail for {original_name}: {e}")
|
||||||
|
thumb_path = None
|
||||||
|
|
||||||
|
# Supersede the old record (preserves tags)
|
||||||
|
record = supersede_image(
|
||||||
|
old_record, dest_path, thumb_path, file_hash, phash, metadata, original_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply any new tags from sidecar
|
||||||
|
_apply_enriched_tags(record, enriched)
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Update existing_phashes list with new dimensions
|
||||||
|
for i, (ph, w, h, img_id) in enumerate(existing_phashes):
|
||||||
|
if img_id == similar_id:
|
||||||
|
existing_phashes[i] = (phash, metadata["width"], metadata["height"], img_id)
|
||||||
|
break
|
||||||
|
|
||||||
|
return (True, phash, record, True) # superseded=True
|
||||||
|
|
||||||
|
# No similar image found (or supersede disabled) - normal import
|
||||||
|
# Enrich metadata from Gallery-DL sidecar JSON if present
|
||||||
|
enriched = _get_enriched_metadata(src_path, metadata)
|
||||||
|
|
||||||
# Copy into /images/<artist>/<base>__<hash[:10]><ext>
|
# Copy into /images/<artist>/<base>__<hash[:10]><ext>
|
||||||
target_dir = os.path.join(dest_dir, artist)
|
target_dir = os.path.join(dest_dir, artist)
|
||||||
@@ -545,6 +797,7 @@ def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phash
|
|||||||
thumb_path = None
|
thumb_path = None
|
||||||
|
|
||||||
# Create DB record (keep display name as original; filepath is content-addressed)
|
# Create DB record (keep display name as original; filepath is content-addressed)
|
||||||
|
# Use enriched taken_at (earliest date wins)
|
||||||
record = ImageRecord(
|
record = ImageRecord(
|
||||||
filename=original_name,
|
filename=original_name,
|
||||||
filepath=dest_path,
|
filepath=dest_path,
|
||||||
@@ -556,7 +809,7 @@ def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phash
|
|||||||
height=metadata["height"],
|
height=metadata["height"],
|
||||||
format=metadata["format"],
|
format=metadata["format"],
|
||||||
camera_model=metadata["camera_model"],
|
camera_model=metadata["camera_model"],
|
||||||
taken_at=metadata["taken_at"],
|
taken_at=enriched.get("taken_at") or metadata["taken_at"],
|
||||||
imported_at=datetime.utcnow()
|
imported_at=datetime.utcnow()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -571,11 +824,81 @@ def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phash
|
|||||||
if artist_tag not in record.tags:
|
if artist_tag not in record.tags:
|
||||||
record.tags.append(artist_tag)
|
record.tags.append(artist_tag)
|
||||||
|
|
||||||
|
# Add tags from Gallery-DL metadata (source:platform, post:platform:artist:id)
|
||||||
|
_apply_enriched_tags(record, enriched)
|
||||||
|
|
||||||
db.session.add(record)
|
db.session.add(record)
|
||||||
if commit:
|
if commit:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return (True, phash, record)
|
return (True, phash, record, False) # superseded=False (new import)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_enriched_metadata(src_path: str, existing_metadata: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Get enriched metadata from Gallery-DL sidecar JSON.
|
||||||
|
Returns empty dict if no sidecar found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.utils.metadata_enrichment import enrich_from_sidecar
|
||||||
|
return enrich_from_sidecar(src_path, existing_metadata)
|
||||||
|
except ImportError:
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Failed to enrich metadata for {src_path}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_enriched_tags(record: ImageRecord, enriched: dict) -> None:
|
||||||
|
"""
|
||||||
|
Apply tags from enriched metadata to an ImageRecord.
|
||||||
|
Creates tags if they don't exist.
|
||||||
|
"""
|
||||||
|
if not enriched or "tags" not in enriched:
|
||||||
|
return
|
||||||
|
|
||||||
|
for tag_info in enriched.get("tags", []):
|
||||||
|
tag_name = tag_info.get("name")
|
||||||
|
tag_kind = tag_info.get("kind")
|
||||||
|
if not tag_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tag = Tag.query.filter_by(name=tag_name).first()
|
||||||
|
if not tag:
|
||||||
|
tag = Tag(name=tag_name, kind=tag_kind)
|
||||||
|
db.session.add(tag)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
if tag not in record.tags:
|
||||||
|
record.tags.append(tag)
|
||||||
|
print(f"[INFO] Added tag: {tag_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_existing_record(record: ImageRecord, src_path: str) -> None:
|
||||||
|
"""
|
||||||
|
Enrich an existing record with metadata from sidecar JSON.
|
||||||
|
Used when a duplicate is found - we still want to merge metadata.
|
||||||
|
|
||||||
|
- Updates taken_at if sidecar date is earlier
|
||||||
|
- Adds source/post tags if not already present
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.utils.metadata_enrichment import enrich_from_sidecar, resolve_date_conflict
|
||||||
|
|
||||||
|
enriched = enrich_from_sidecar(src_path, {"taken_at": record.taken_at})
|
||||||
|
if not enriched.get("raw_metadata"):
|
||||||
|
return # No sidecar found
|
||||||
|
|
||||||
|
# Update date if sidecar has an earlier date
|
||||||
|
new_date = resolve_date_conflict(record.taken_at, enriched.get("taken_at"))
|
||||||
|
if new_date != record.taken_at:
|
||||||
|
print(f"[INFO] Updating taken_at for {record.filename}: {record.taken_at} -> {new_date}")
|
||||||
|
record.taken_at = new_date
|
||||||
|
|
||||||
|
# Add any new tags
|
||||||
|
_apply_enriched_tags(record, enriched)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WARN] Failed to enrich existing record: {e}")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -593,6 +916,10 @@ def generate_thumbnail(
|
|||||||
Save thumbnails mirrored under /images/thumbs/<...>.
|
Save thumbnails mirrored under /images/thumbs/<...>.
|
||||||
If prefer_jpeg is False (default), transparent images save as PNG, others as JPEG.
|
If prefer_jpeg is False (default), transparent images save as PNG, others as JPEG.
|
||||||
If prefer_jpeg is True, all thumbs are JPEG with transparency flattened to `jpeg_bg`.
|
If prefer_jpeg is True, all thumbs are JPEG with transparency flattened to `jpeg_bg`.
|
||||||
|
|
||||||
|
For images with extreme aspect ratios (very tall like comic strips, or very wide),
|
||||||
|
crops to the center region before thumbnailing to produce better quality previews.
|
||||||
|
|
||||||
Returns the absolute path to the thumbnail.
|
Returns the absolute path to the thumbnail.
|
||||||
"""
|
"""
|
||||||
images_root = Path("/images").resolve()
|
images_root = Path("/images").resolve()
|
||||||
@@ -609,7 +936,27 @@ def generate_thumbnail(
|
|||||||
# Decide output extension/format
|
# Decide output extension/format
|
||||||
with Image.open(image_path) as img:
|
with Image.open(image_path) as img:
|
||||||
img = ImageOps.exif_transpose(img) # honor camera rotation
|
img = ImageOps.exif_transpose(img) # honor camera rotation
|
||||||
img.thumbnail(size)
|
|
||||||
|
# Handle extreme aspect ratios by cropping to a more balanced region
|
||||||
|
# This prevents very tall/wide images from becoming tiny slivers
|
||||||
|
w, h = img.size
|
||||||
|
aspect_ratio = w / h if h > 0 else 1
|
||||||
|
|
||||||
|
# If aspect ratio is extreme (< 0.5 means very tall, > 2.0 means very wide)
|
||||||
|
# crop to a more balanced region before thumbnailing
|
||||||
|
if aspect_ratio < 0.5:
|
||||||
|
# Very tall image (like comic strip) - crop from top portion
|
||||||
|
# Take a region that's closer to square, from the top
|
||||||
|
crop_height = min(h, w * 2) # At most 2:1 tall
|
||||||
|
img = img.crop((0, 0, w, crop_height))
|
||||||
|
elif aspect_ratio > 2.0:
|
||||||
|
# Very wide image - crop from center
|
||||||
|
crop_width = min(w, h * 2) # At most 2:1 wide
|
||||||
|
left = (w - crop_width) // 2
|
||||||
|
img = img.crop((left, 0, left + crop_width, h))
|
||||||
|
|
||||||
|
# Use LANCZOS resampling for high-quality downscaling
|
||||||
|
img.thumbnail(size, Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
has_alpha = _has_alpha(img)
|
has_alpha = _has_alpha(img)
|
||||||
|
|
||||||
@@ -687,24 +1034,69 @@ def calculate_hash(file_path: str) -> str:
|
|||||||
return hash_func.hexdigest()
|
return hash_func.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def calculate_perceptual_hash(file_path: str):
|
def calculate_perceptual_hash(file_path: str, hash_size: int = 16):
|
||||||
|
"""
|
||||||
|
Calculate a perceptual hash for an image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the image file
|
||||||
|
hash_size: Size of the hash grid (default 16 for 256-bit hash)
|
||||||
|
hash_size=8 gives 64-bit, hash_size=16 gives 256-bit
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ImageHash object or None if hashing fails (e.g., for videos)
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with Image.open(file_path) as img:
|
with Image.open(file_path) as img:
|
||||||
return imagehash.phash(img)
|
return imagehash.phash(img, hash_size=hash_size)
|
||||||
except Exception:
|
except Exception:
|
||||||
# videos or unreadable images land here (phash not applicable)
|
# videos or unreadable images land here (phash not applicable)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def is_similar_image(phash, width: int, height: int, existing_phashes, threshold: int = 2) -> bool:
|
def find_similar_image(phash, width: int, height: int, existing_phashes, threshold: int = 10):
|
||||||
for existing, ew, eh in existing_phashes:
|
"""
|
||||||
|
Find visually similar images and determine relationship.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
phash: Perceptual hash of the new image
|
||||||
|
width: Width of the new image
|
||||||
|
height: Height of the new image
|
||||||
|
existing_phashes: List of (phash, width, height, image_id) tuples for existing images
|
||||||
|
threshold: Maximum Hamming distance to consider images similar
|
||||||
|
Default is 10 for 256-bit hashes (was 2 for 64-bit)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (relationship, image_id) where relationship is one of:
|
||||||
|
- None: No similar image found
|
||||||
|
- "larger_exists": A larger or equal similar image exists (skip new image)
|
||||||
|
- "smaller_exists": A smaller similar image exists (supersede it)
|
||||||
|
image_id is the ID of the matching image, or None
|
||||||
|
"""
|
||||||
|
for existing_phash, ew, eh, image_id in existing_phashes:
|
||||||
try:
|
try:
|
||||||
distance = phash - existing
|
distance = phash - existing_phash
|
||||||
if distance <= threshold and ew >= width and eh >= height:
|
if distance <= threshold:
|
||||||
return True
|
# Similar image found - check size relationship
|
||||||
|
if ew >= width and eh >= height:
|
||||||
|
# Existing is larger or equal - skip new image
|
||||||
|
return ("larger_exists", image_id)
|
||||||
|
elif width > ew or height > eh:
|
||||||
|
# New image is larger - supersede the existing one
|
||||||
|
return ("smaller_exists", image_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WARN] Failed pHash comparison: {e}")
|
print(f"[WARN] Failed pHash comparison: {e}")
|
||||||
return False
|
return (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def is_similar_image(phash, width: int, height: int, existing_phashes, threshold: int = 10) -> bool:
|
||||||
|
"""
|
||||||
|
Check if an image is visually similar to any existing larger image.
|
||||||
|
|
||||||
|
Legacy wrapper for find_similar_image - returns True only if a larger version exists.
|
||||||
|
"""
|
||||||
|
relationship, _ = find_similar_image(phash, width, height, existing_phashes, threshold)
|
||||||
|
return relationship == "larger_exists"
|
||||||
|
|
||||||
|
|
||||||
def extract_metadata(file_path: str) -> dict:
|
def extract_metadata(file_path: str) -> dict:
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
# app/utils/metadata_enrichment.py
|
||||||
|
"""
|
||||||
|
Metadata enrichment from Gallery-DL sidecar JSON files.
|
||||||
|
|
||||||
|
This module handles:
|
||||||
|
- Detection and parsing of Gallery-DL sidecar .json files
|
||||||
|
- Extraction of metadata (dates, tags, post info) from various platforms
|
||||||
|
- Tag generation for source tracking (source:platform, post:platform:artist:id)
|
||||||
|
- Date conflict resolution (earliest date wins)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
log = logging.getLogger("metadata-enrichment")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Sidecar File Detection
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def find_sidecar_json(image_path: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Find the Gallery-DL sidecar JSON file for an image.
|
||||||
|
|
||||||
|
Gallery-DL creates JSON files with the UUID portion of the filename.
|
||||||
|
For example:
|
||||||
|
Image: 01_BBEFD45F-8775-4DF9-8BCA-99E6063E3616.jpg
|
||||||
|
JSON: BBEFD45F-8775-4DF9-8BCA-99E6063E3616.json
|
||||||
|
|
||||||
|
Also checks for direct {filename}.json pattern.
|
||||||
|
|
||||||
|
Returns the path to the sidecar JSON if found, None otherwise.
|
||||||
|
"""
|
||||||
|
image_path = Path(image_path)
|
||||||
|
parent_dir = image_path.parent
|
||||||
|
stem = image_path.stem # filename without extension
|
||||||
|
|
||||||
|
# Pattern 1: Direct {filename}.json (e.g., image.jpg -> image.jpg.json)
|
||||||
|
direct_json = image_path.with_suffix(image_path.suffix + ".json")
|
||||||
|
if direct_json.exists():
|
||||||
|
return str(direct_json)
|
||||||
|
|
||||||
|
# Pattern 2: Extract UUID from filename and look for {uuid}.json
|
||||||
|
# Common patterns: "01_UUID", "UUID", "{num}_{UUID}"
|
||||||
|
uuid_pattern = re.compile(r'([A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12})', re.IGNORECASE)
|
||||||
|
match = uuid_pattern.search(stem)
|
||||||
|
if match:
|
||||||
|
uuid = match.group(1)
|
||||||
|
uuid_json = parent_dir / f"{uuid}.json"
|
||||||
|
if uuid_json.exists():
|
||||||
|
return str(uuid_json)
|
||||||
|
|
||||||
|
# Pattern 3: Just the stem without prefix numbers
|
||||||
|
# e.g., "01_filename" -> look for "filename.json"
|
||||||
|
stem_no_prefix = re.sub(r'^\d+_', '', stem)
|
||||||
|
if stem_no_prefix != stem:
|
||||||
|
alt_json = parent_dir / f"{stem_no_prefix}.json"
|
||||||
|
if alt_json.exists():
|
||||||
|
return str(alt_json)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_sidecar_metadata(json_path: str) -> Optional[dict]:
|
||||||
|
"""Load and parse a Gallery-DL sidecar JSON file."""
|
||||||
|
try:
|
||||||
|
with open(json_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, IOError) as e:
|
||||||
|
log.warning(f"Failed to load sidecar JSON {json_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Metadata Extraction (Platform-specific)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def extract_platform(metadata: dict) -> Optional[str]:
|
||||||
|
"""Extract the platform name from metadata."""
|
||||||
|
# Check common fields
|
||||||
|
if "category" in metadata:
|
||||||
|
return metadata["category"].lower()
|
||||||
|
|
||||||
|
# Infer from URL patterns
|
||||||
|
url = metadata.get("url", "") or metadata.get("post_url", "")
|
||||||
|
if "patreon.com" in url:
|
||||||
|
return "patreon"
|
||||||
|
if "hentai-foundry" in url or "hentaifoundry" in url.lower():
|
||||||
|
return "hentaifoundry"
|
||||||
|
if "subscribestar" in url:
|
||||||
|
return "subscribestar"
|
||||||
|
if "discord.com" in url:
|
||||||
|
return "discord"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_artist(metadata: dict) -> Optional[str]:
|
||||||
|
"""Extract the artist/creator name from metadata."""
|
||||||
|
# Try various common fields
|
||||||
|
if "creator" in metadata and isinstance(metadata["creator"], dict):
|
||||||
|
return metadata["creator"].get("vanity") or metadata["creator"].get("full_name")
|
||||||
|
|
||||||
|
if "author_name" in metadata:
|
||||||
|
return metadata["author_name"]
|
||||||
|
|
||||||
|
if "artist" in metadata:
|
||||||
|
return metadata["artist"]
|
||||||
|
|
||||||
|
# For hentaifoundry
|
||||||
|
if "user" in metadata:
|
||||||
|
return metadata["user"]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_post_id(metadata: dict) -> Optional[str]:
|
||||||
|
"""Extract the post ID from metadata."""
|
||||||
|
if "id" in metadata:
|
||||||
|
return str(metadata["id"])
|
||||||
|
|
||||||
|
if "post_id" in metadata:
|
||||||
|
return str(metadata["post_id"])
|
||||||
|
|
||||||
|
if "message_id" in metadata: # Discord
|
||||||
|
return str(metadata["message_id"])
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_post_title(metadata: dict) -> Optional[str]:
|
||||||
|
"""Extract the post title from metadata."""
|
||||||
|
return metadata.get("title")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_date(metadata: dict) -> Optional[datetime]:
|
||||||
|
"""
|
||||||
|
Extract the publication/post date from metadata.
|
||||||
|
Tries multiple common date fields and formats.
|
||||||
|
"""
|
||||||
|
date_fields = ["published_at", "date", "created_at", "timestamp"]
|
||||||
|
|
||||||
|
for field in date_fields:
|
||||||
|
if field not in metadata:
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = metadata[field]
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle various formats
|
||||||
|
parsed = _parse_date(value)
|
||||||
|
if parsed:
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value) -> Optional[datetime]:
|
||||||
|
"""Parse a date value that could be string, int, or already datetime."""
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
# Unix timestamp
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(value)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
# Try common formats
|
||||||
|
formats = [
|
||||||
|
"%Y-%m-%dT%H:%M:%S.%f%z", # ISO with microseconds and timezone
|
||||||
|
"%Y-%m-%dT%H:%M:%S%z", # ISO with timezone
|
||||||
|
"%Y-%m-%dT%H:%M:%S.%f", # ISO with microseconds
|
||||||
|
"%Y-%m-%dT%H:%M:%S", # ISO basic
|
||||||
|
"%Y-%m-%d %H:%M:%S", # Space-separated
|
||||||
|
"%Y-%m-%d", # Date only
|
||||||
|
]
|
||||||
|
|
||||||
|
# Handle timezone offset with colon (Python < 3.11 compat)
|
||||||
|
# e.g., "2023-08-01T04:20:02.000+00:00" -> "2023-08-01T04:20:02.000+0000"
|
||||||
|
normalized = re.sub(r'([+-]\d{2}):(\d{2})$', r'\1\2', value)
|
||||||
|
|
||||||
|
for fmt in formats:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(normalized, fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try without timezone info
|
||||||
|
# Strip timezone suffix for naive parsing
|
||||||
|
stripped = re.sub(r'[+-]\d{2}:?\d{2}$', '', value)
|
||||||
|
stripped = re.sub(r'Z$', '', stripped)
|
||||||
|
for fmt in formats:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(stripped, fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_content_tags(metadata: dict) -> list[str]:
|
||||||
|
"""
|
||||||
|
Extract content tags from metadata (not to be confused with DB tags).
|
||||||
|
These are tags the creator applied to the post.
|
||||||
|
"""
|
||||||
|
tags = []
|
||||||
|
|
||||||
|
# Direct tags field
|
||||||
|
if "tags" in metadata and isinstance(metadata["tags"], list):
|
||||||
|
tags.extend(metadata["tags"])
|
||||||
|
|
||||||
|
return [str(t) for t in tags if t]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tag Generation
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def generate_source_tags(metadata: dict, artist_override: Optional[str] = None) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Generate tags from Gallery-DL metadata.
|
||||||
|
|
||||||
|
Returns a list of tag dictionaries with 'name' and 'kind' keys.
|
||||||
|
Tag format ensures uniqueness:
|
||||||
|
- source:{platform} (kind='source')
|
||||||
|
- post:{platform}:{artist}:{post_id} (kind='post')
|
||||||
|
"""
|
||||||
|
tags = []
|
||||||
|
|
||||||
|
platform = extract_platform(metadata)
|
||||||
|
artist = artist_override or extract_artist(metadata)
|
||||||
|
post_id = extract_post_id(metadata)
|
||||||
|
|
||||||
|
# Source platform tag
|
||||||
|
if platform:
|
||||||
|
tags.append({
|
||||||
|
"name": f"source:{platform}",
|
||||||
|
"kind": "source"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Post tag (unique across platform + artist + post_id)
|
||||||
|
if platform and artist and post_id:
|
||||||
|
# Sanitize artist name for tag (lowercase, replace spaces)
|
||||||
|
safe_artist = artist.lower().replace(" ", "_")
|
||||||
|
tags.append({
|
||||||
|
"name": f"post:{platform}:{safe_artist}:{post_id}",
|
||||||
|
"kind": "post"
|
||||||
|
})
|
||||||
|
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Date Conflict Resolution
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def resolve_date_conflict(existing_date: Optional[datetime], new_date: Optional[datetime]) -> Optional[datetime]:
|
||||||
|
"""
|
||||||
|
Resolve date conflicts by keeping the earliest date.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
existing_date: The currently stored date (may be None)
|
||||||
|
new_date: The new date from metadata (may be None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The earliest non-None date, or None if both are None
|
||||||
|
"""
|
||||||
|
if existing_date is None:
|
||||||
|
return new_date
|
||||||
|
if new_date is None:
|
||||||
|
return existing_date
|
||||||
|
|
||||||
|
# Return the earlier date
|
||||||
|
return min(existing_date, new_date)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Main Enrichment Function
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def enrich_from_sidecar(image_path: str, existing_metadata: Optional[dict] = None) -> dict:
|
||||||
|
"""
|
||||||
|
Enrich image metadata from Gallery-DL sidecar JSON.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: Path to the image file
|
||||||
|
existing_metadata: Optional dict with existing metadata (e.g., from EXIF)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with enriched metadata:
|
||||||
|
{
|
||||||
|
"platform": str or None,
|
||||||
|
"artist": str or None,
|
||||||
|
"post_id": str or None,
|
||||||
|
"post_title": str or None,
|
||||||
|
"taken_at": datetime or None,
|
||||||
|
"tags": [{"name": str, "kind": str}, ...],
|
||||||
|
"raw_metadata": dict or None (the full sidecar content)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"platform": None,
|
||||||
|
"artist": None,
|
||||||
|
"post_id": None,
|
||||||
|
"post_title": None,
|
||||||
|
"taken_at": None,
|
||||||
|
"tags": [],
|
||||||
|
"raw_metadata": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find and load sidecar
|
||||||
|
sidecar_path = find_sidecar_json(image_path)
|
||||||
|
if not sidecar_path:
|
||||||
|
return result
|
||||||
|
|
||||||
|
metadata = load_sidecar_metadata(sidecar_path)
|
||||||
|
if not metadata:
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["raw_metadata"] = metadata
|
||||||
|
|
||||||
|
# Extract fields
|
||||||
|
result["platform"] = extract_platform(metadata)
|
||||||
|
result["artist"] = extract_artist(metadata)
|
||||||
|
result["post_id"] = extract_post_id(metadata)
|
||||||
|
result["post_title"] = extract_post_title(metadata)
|
||||||
|
|
||||||
|
# Extract date and resolve conflicts with existing
|
||||||
|
sidecar_date = extract_date(metadata)
|
||||||
|
existing_date = existing_metadata.get("taken_at") if existing_metadata else None
|
||||||
|
result["taken_at"] = resolve_date_conflict(existing_date, sidecar_date)
|
||||||
|
|
||||||
|
# Generate tags
|
||||||
|
result["tags"] = generate_source_tags(metadata, result["artist"])
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
# app/utils/version_migration.py
|
||||||
|
"""
|
||||||
|
Version tracking and data migration utilities.
|
||||||
|
|
||||||
|
This module handles:
|
||||||
|
- Application version tracking via AppSettings
|
||||||
|
- Automatic phash recomputation when version changes
|
||||||
|
- Settings retrieval/storage from database
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
import imagehash
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models import AppSettings, ImageRecord
|
||||||
|
|
||||||
|
log = logging.getLogger("version-migration")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Version and pHash Configuration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
APP_VERSION = "26.01.19.1" # Format: YY.MM.DD.iteration
|
||||||
|
|
||||||
|
# pHash configuration - 256-bit (hash_size=16 -> 16x16 grid)
|
||||||
|
PHASH_HASH_SIZE = 16
|
||||||
|
PHASH_BITS = PHASH_HASH_SIZE * PHASH_HASH_SIZE # 256
|
||||||
|
PHASH_THRESHOLD_DEFAULT = 10 # Scaled from 2 (64-bit) to 10 (256-bit)
|
||||||
|
|
||||||
|
# Batch size for phash recomputation
|
||||||
|
PHASH_RECOMPUTE_BATCH_SIZE = int(os.environ.get("PHASH_RECOMPUTE_BATCH_SIZE", "100"))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Settings Access Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_setting(key: str, default: Optional[str] = None) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Retrieve a setting value from the database.
|
||||||
|
Returns default if the key doesn't exist.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
setting = AppSettings.query.filter_by(key=key).first()
|
||||||
|
if setting is not None:
|
||||||
|
return setting.value
|
||||||
|
return default
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Failed to get setting '{key}': {e}")
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def set_setting(key: str, value: str) -> None:
|
||||||
|
"""
|
||||||
|
Store a setting value in the database.
|
||||||
|
Creates the key if it doesn't exist, updates if it does.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
setting = AppSettings.query.filter_by(key=key).first()
|
||||||
|
if setting is None:
|
||||||
|
setting = AppSettings(key=key, value=value)
|
||||||
|
db.session.add(setting)
|
||||||
|
else:
|
||||||
|
setting.value = value
|
||||||
|
db.session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to set setting '{key}': {e}")
|
||||||
|
db.session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting_int(key: str, default: int) -> int:
|
||||||
|
"""Get a setting as an integer."""
|
||||||
|
val = get_setting(key)
|
||||||
|
if val is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(val)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting_float(key: str, default: float) -> float:
|
||||||
|
"""Get a setting as a float."""
|
||||||
|
val = get_setting(key)
|
||||||
|
if val is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting_bool(key: str, default: bool) -> bool:
|
||||||
|
"""Get a setting as a boolean."""
|
||||||
|
val = get_setting(key)
|
||||||
|
if val is None:
|
||||||
|
return default
|
||||||
|
return val.lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Import Settings (DB-backed)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_import_settings() -> dict:
|
||||||
|
"""
|
||||||
|
Load import filter settings from database, with environment variable fallbacks.
|
||||||
|
This replaces the file-based load_import_settings() function.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"min_width": get_setting_int("import_min_width", int(os.environ.get("IMPORT_MIN_WIDTH", "0"))),
|
||||||
|
"min_height": get_setting_int("import_min_height", int(os.environ.get("IMPORT_MIN_HEIGHT", "0"))),
|
||||||
|
"skip_transparent": get_setting_bool("import_skip_transparent", os.environ.get("IMPORT_SKIP_TRANSPARENT", "false").lower() == "true"),
|
||||||
|
"transparency_threshold": get_setting_float("import_transparency_threshold", float(os.environ.get("IMPORT_TRANSPARENCY_THRESHOLD", "0.9"))),
|
||||||
|
"phash_threshold": get_setting_int("phash_threshold", PHASH_THRESHOLD_DEFAULT),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# pHash Computation (256-bit)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def calculate_phash_256(file_path: str) -> Optional[imagehash.ImageHash]:
|
||||||
|
"""
|
||||||
|
Calculate a 256-bit perceptual hash for an image.
|
||||||
|
Uses hash_size=16 for a 16x16 grid = 256 bits.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with Image.open(file_path) as img:
|
||||||
|
return imagehash.phash(img, hash_size=PHASH_HASH_SIZE)
|
||||||
|
except Exception as e:
|
||||||
|
log.debug(f"Could not compute phash for {file_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Version Migration Logic
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def recompute_all_phashes() -> dict:
|
||||||
|
"""
|
||||||
|
Recompute perceptual hashes for all images using 256-bit hashing.
|
||||||
|
Returns statistics about the operation.
|
||||||
|
"""
|
||||||
|
stats = {
|
||||||
|
"total": 0,
|
||||||
|
"updated": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
"failed": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Starting phash recomputation (256-bit)...")
|
||||||
|
t_start = time.time()
|
||||||
|
|
||||||
|
batch_size = PHASH_RECOMPUTE_BATCH_SIZE
|
||||||
|
last_id = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Fetch batch of images
|
||||||
|
images = (
|
||||||
|
ImageRecord.query
|
||||||
|
.filter(ImageRecord.id > last_id)
|
||||||
|
.order_by(ImageRecord.id.asc())
|
||||||
|
.limit(batch_size)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
break
|
||||||
|
|
||||||
|
batch_updated = 0
|
||||||
|
for img in images:
|
||||||
|
stats["total"] += 1
|
||||||
|
|
||||||
|
# Skip videos (no phash)
|
||||||
|
ext = (img.format or "").lower()
|
||||||
|
if ext in ("mp4", "mov") or (img.filepath and img.filepath.lower().endswith((".mp4", ".mov"))):
|
||||||
|
stats["skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if file doesn't exist
|
||||||
|
if not img.filepath or not os.path.exists(img.filepath):
|
||||||
|
log.warning(f"File not found for image id={img.id}: {img.filepath}")
|
||||||
|
stats["failed"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Compute new phash
|
||||||
|
try:
|
||||||
|
phash = calculate_phash_256(img.filepath)
|
||||||
|
if phash:
|
||||||
|
img.perceptual_hash = str(phash)
|
||||||
|
batch_updated += 1
|
||||||
|
stats["updated"] += 1
|
||||||
|
else:
|
||||||
|
stats["failed"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Failed to compute phash for id={img.id}: {e}")
|
||||||
|
stats["failed"] += 1
|
||||||
|
|
||||||
|
# Commit batch
|
||||||
|
db.session.commit()
|
||||||
|
last_id = images[-1].id
|
||||||
|
log.info(f"Processed batch up to id={last_id}, updated={batch_updated}")
|
||||||
|
|
||||||
|
elapsed = time.time() - t_start
|
||||||
|
log.info(
|
||||||
|
f"pHash recomputation complete in {elapsed:.1f}s: "
|
||||||
|
f"total={stats['total']}, updated={stats['updated']}, "
|
||||||
|
f"skipped={stats['skipped']}, failed={stats['failed']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_run_migrations() -> None:
|
||||||
|
"""
|
||||||
|
Check the stored app version and run data migrations if needed.
|
||||||
|
|
||||||
|
This function:
|
||||||
|
1. Reads the stored app_version from the database
|
||||||
|
2. Compares it with the current APP_VERSION
|
||||||
|
3. If different, triggers necessary data migrations (e.g., phash recomputation)
|
||||||
|
4. Updates the stored version
|
||||||
|
"""
|
||||||
|
log.info(f"Checking app version (current: {APP_VERSION})...")
|
||||||
|
|
||||||
|
stored_version = get_setting("app_version")
|
||||||
|
phash_version = get_setting("phash_version")
|
||||||
|
|
||||||
|
log.info(f"Stored version: {stored_version}, pHash version: {phash_version}")
|
||||||
|
|
||||||
|
# Initialize settings if this is first run
|
||||||
|
if stored_version is None:
|
||||||
|
log.info("First run detected, initializing settings...")
|
||||||
|
_initialize_default_settings()
|
||||||
|
stored_version = "0.0.0.0" # Force migration on first run
|
||||||
|
|
||||||
|
# Check if phash recomputation is needed
|
||||||
|
needs_phash_recompute = (
|
||||||
|
phash_version is None or
|
||||||
|
phash_version != APP_VERSION or
|
||||||
|
get_setting_int("phash_bits", 64) != PHASH_BITS
|
||||||
|
)
|
||||||
|
|
||||||
|
if needs_phash_recompute:
|
||||||
|
log.info(f"pHash migration needed (stored bits: {get_setting('phash_bits')}, new: {PHASH_BITS})")
|
||||||
|
|
||||||
|
# Update phash configuration
|
||||||
|
set_setting("phash_bits", str(PHASH_BITS))
|
||||||
|
set_setting("phash_hash_size", str(PHASH_HASH_SIZE))
|
||||||
|
|
||||||
|
# Recompute all hashes
|
||||||
|
recompute_all_phashes()
|
||||||
|
|
||||||
|
# Mark phash version as current
|
||||||
|
set_setting("phash_version", APP_VERSION)
|
||||||
|
|
||||||
|
# Update app version
|
||||||
|
if stored_version != APP_VERSION:
|
||||||
|
log.info(f"Updating app version from {stored_version} to {APP_VERSION}")
|
||||||
|
set_setting("app_version", APP_VERSION)
|
||||||
|
|
||||||
|
log.info("Version check complete.")
|
||||||
|
|
||||||
|
|
||||||
|
def _initialize_default_settings() -> None:
|
||||||
|
"""Initialize default settings on first run."""
|
||||||
|
defaults = {
|
||||||
|
"app_version": APP_VERSION,
|
||||||
|
"phash_version": None, # Will trigger recomputation
|
||||||
|
"phash_bits": str(PHASH_BITS),
|
||||||
|
"phash_hash_size": str(PHASH_HASH_SIZE),
|
||||||
|
"phash_threshold": str(PHASH_THRESHOLD_DEFAULT),
|
||||||
|
"import_min_width": "0",
|
||||||
|
"import_min_height": "0",
|
||||||
|
"import_skip_transparent": "false",
|
||||||
|
"import_transparency_threshold": "0.9",
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value in defaults.items():
|
||||||
|
if get_setting(key) is None and value is not None:
|
||||||
|
set_setting(key, value)
|
||||||
@@ -20,6 +20,10 @@ if [ "${RUN_DB_MIGRATIONS:-1}" = "1" ]; then
|
|||||||
sleep "$SLEEP_SECS"
|
sleep "$SLEEP_SECS"
|
||||||
i=$((i+1))
|
i=$((i+1))
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Run version check and data migrations (e.g., phash recomputation)
|
||||||
|
echo "Checking app version and running data migrations..."
|
||||||
|
flask version-check
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Start the image import worker exactly once per container ---
|
# --- Start the image import worker exactly once per container ---
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""Add app_settings table and expand perceptual_hash to 64 chars for 256-bit hashes
|
||||||
|
|
||||||
|
Revision ID: b26011901
|
||||||
|
Revises: a46e641430b8
|
||||||
|
Create Date: 2026-01-19
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'b26011901'
|
||||||
|
down_revision = 'a46e641430b8'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Create app_settings table
|
||||||
|
op.create_table(
|
||||||
|
'app_settings',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('key', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('value', sa.Text(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('key')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Expand perceptual_hash column from 16 to 64 chars
|
||||||
|
bind = op.get_bind()
|
||||||
|
dialect = bind.dialect.name
|
||||||
|
|
||||||
|
if dialect == "sqlite":
|
||||||
|
# SQLite requires table recreation for column type changes.
|
||||||
|
# IMPORTANT: When recreating image_record, we must also recreate
|
||||||
|
# image_tags to preserve foreign key relationships.
|
||||||
|
|
||||||
|
# Step 1: Backup image_tags data
|
||||||
|
op.execute("CREATE TABLE image_tags_backup AS SELECT * FROM image_tags")
|
||||||
|
|
||||||
|
# Step 2: Drop image_tags (it references image_record)
|
||||||
|
op.drop_table("image_tags")
|
||||||
|
|
||||||
|
# Step 3: Recreate image_record with new column size
|
||||||
|
with op.batch_alter_table("image_record", recreate="always") as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
'perceptual_hash',
|
||||||
|
existing_type=sa.String(length=16),
|
||||||
|
type_=sa.String(length=64),
|
||||||
|
existing_nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Recreate image_tags with proper FK constraints
|
||||||
|
op.create_table(
|
||||||
|
"image_tags",
|
||||||
|
sa.Column("image_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("tag_id", sa.Integer(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("image_id", "tag_id"),
|
||||||
|
sa.ForeignKeyConstraint(["image_id"], ["image_record.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["tag_id"], ["tag.id"], ondelete="CASCADE"),
|
||||||
|
sa.UniqueConstraint("image_id", "tag_id", name="uq_image_tag"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 5: Restore image_tags data
|
||||||
|
op.execute("INSERT INTO image_tags (image_id, tag_id) SELECT image_id, tag_id FROM image_tags_backup")
|
||||||
|
|
||||||
|
# Step 6: Drop backup table
|
||||||
|
op.drop_table("image_tags_backup")
|
||||||
|
else:
|
||||||
|
# PostgreSQL and others can alter directly
|
||||||
|
op.alter_column(
|
||||||
|
'image_record',
|
||||||
|
'perceptual_hash',
|
||||||
|
existing_type=sa.String(length=16),
|
||||||
|
type_=sa.String(length=64),
|
||||||
|
existing_nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
dialect = bind.dialect.name
|
||||||
|
|
||||||
|
# Shrink perceptual_hash back to 16 chars (will truncate data!)
|
||||||
|
if dialect == "sqlite":
|
||||||
|
# Same approach as upgrade: backup image_tags, recreate image_record,
|
||||||
|
# then restore image_tags to preserve FK relationships
|
||||||
|
|
||||||
|
# Step 1: Backup image_tags data
|
||||||
|
op.execute("CREATE TABLE image_tags_backup AS SELECT * FROM image_tags")
|
||||||
|
|
||||||
|
# Step 2: Drop image_tags (it references image_record)
|
||||||
|
op.drop_table("image_tags")
|
||||||
|
|
||||||
|
# Step 3: Recreate image_record with old column size
|
||||||
|
with op.batch_alter_table("image_record", recreate="always") as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
'perceptual_hash',
|
||||||
|
existing_type=sa.String(length=64),
|
||||||
|
type_=sa.String(length=16),
|
||||||
|
existing_nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Recreate image_tags with proper FK constraints
|
||||||
|
op.create_table(
|
||||||
|
"image_tags",
|
||||||
|
sa.Column("image_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("tag_id", sa.Integer(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("image_id", "tag_id"),
|
||||||
|
sa.ForeignKeyConstraint(["image_id"], ["image_record.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["tag_id"], ["tag.id"], ondelete="CASCADE"),
|
||||||
|
sa.UniqueConstraint("image_id", "tag_id", name="uq_image_tag"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 5: Restore image_tags data
|
||||||
|
op.execute("INSERT INTO image_tags (image_id, tag_id) SELECT image_id, tag_id FROM image_tags_backup")
|
||||||
|
|
||||||
|
# Step 6: Drop backup table
|
||||||
|
op.drop_table("image_tags_backup")
|
||||||
|
else:
|
||||||
|
op.alter_column(
|
||||||
|
'image_record',
|
||||||
|
'perceptual_hash',
|
||||||
|
existing_type=sa.String(length=64),
|
||||||
|
type_=sa.String(length=16),
|
||||||
|
existing_nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop app_settings table
|
||||||
|
op.drop_table('app_settings')
|
||||||
Reference in New Issue
Block a user