switched to tagging and added views to support it, in progress for other tagging functions.

This commit is contained in:
Bryan Van Deusen
2025-08-14 21:36:27 -04:00
parent e9793ba38c
commit 3ff34ec9c2
12 changed files with 943 additions and 349 deletions
+1
View File
@@ -1,6 +1,7 @@
# Ignore imported and generated image folders # Ignore imported and generated image folders
images/ images/
import/ import/
imagerepo/
# Optional: Ignore SQLite DB, Python cache, virtual env, etc. # Optional: Ignore SQLite DB, Python cache, virtual env, etc.
*.db *.db
+11 -1
View File
@@ -5,12 +5,22 @@ from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate from flask_migrate import Migrate
from flask_login import LoginManager from flask_login import LoginManager
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from sqlalchemy import event
from sqlalchemy.engine import Engine
import sqlite3
db = SQLAlchemy() db = SQLAlchemy()
migrate = Migrate() migrate = Migrate()
login_manager = LoginManager() login_manager = LoginManager()
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
# Only apply to SQLite connections
if isinstance(dbapi_connection, sqlite3.Connection):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys = ON")
cursor.close()
def create_app(config_class='config.Config'): def create_app(config_class='config.Config'):
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(config_class) app.config.from_object(config_class)
+155 -49
View File
@@ -1,22 +1,23 @@
# app/main.py # app/main.py
from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request, jsonify
from flask_login import login_required, current_user from flask_login import login_required, current_user
from sqlalchemy import desc, func from sqlalchemy import desc, func, text
from sqlalchemy.orm import joinedload
from app.utils.image_importer import import_images_task from app.utils.image_importer import import_images_task
from app.models import ImageRecord from app.models import ImageRecord, Tag, ArchiveRecord, image_tags
from app import db from app import db
import os import os
import shutil
main = Blueprint('main', __name__) main = Blueprint('main', __name__)
@main.route('/') @main.route('/')
def index(): def index():
from random import choice from random import choice # (kept from your original)
from app.models import ImageRecord
image = ImageRecord.query.order_by(func.random()).first() image = ImageRecord.query.order_by(func.random()).first()
background_url = None background_url = None
@@ -27,24 +28,40 @@ def index():
return render_template('index.html', background_url=background_url) return render_template('index.html', background_url=background_url)
@main.route('/gallery') @main.route('/gallery')
@login_required @login_required
def gallery(): def gallery():
"""
Gallery with pagination and optional tag filter (?tag=artist:NAME or any tag name).
Keeps your pagination shape so modal-pagination.js continues to work.
"""
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 35, type=int) per_page = request.args.get('per_page', 35, type=int)
tag_name = request.args.get('tag')
# Base query + eager-load tags (cheap and future-proofs tag chips)
q = ImageRecord.query.options(joinedload(ImageRecord.tags))
images = ImageRecord.query.order_by( # Optional tag filter
desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)) if tag_name:
).paginate(page=page, per_page=per_page) q = q.join(ImageRecord.tags).filter(Tag.name == tag_name)
images = q.order_by(desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))) \
.paginate(page=page, per_page=per_page)
# Preserve next/prev URLs (carry tag= so modal pagination fetches stay filtered)
next_url = url_for('main.gallery', page=images.next_num, per_page=per_page, tag=tag_name) if images.has_next else ''
prev_url = url_for('main.gallery', page=images.prev_num, per_page=per_page, tag=tag_name) if images.has_prev else ''
return render_template( return render_template(
'gallery.html', 'gallery.html',
images=images, images=images,
active_tag=tag_name,
has_next=images.has_next, has_next=images.has_next,
next_page_url=url_for('main.gallery', page=images.next_num, per_page=per_page) if images.has_next else '', next_page_url=next_url,
has_prev=images.has_prev, has_prev=images.has_prev,
prev_page_url=url_for('main.gallery', page=images.prev_num, per_page=per_page) if images.has_prev else '' prev_page_url=prev_url
) )
@@ -52,51 +69,58 @@ def gallery():
def serve_image(filename): def serve_image(filename):
return send_from_directory('/images', filename) return send_from_directory('/images', filename)
@main.route('/artist/<artist_name>')
@main.route('/tags')
@login_required @login_required
def artist_gallery(artist_name): def tag_list():
page = request.args.get('page', 1, type=int) """
per_page = request.args.get('per_page', 35, type=int) Generic tag explorer:
/tags -> all tags
/tags?kind=user -> only user tags
/tags?kind=artist -> only artist tags
/tags?kind=archive -> only archive tags
Shows a few preview images per tag.
"""
images_per_tag = 3
kind = request.args.get('kind') # 'artist' | 'archive' | 'user' | None
images = ImageRecord.query.filter_by(artist=artist_name).order_by( q = Tag.query
desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)) if kind:
).paginate(page=page, per_page=per_page) q = q.filter_by(kind=kind)
return render_template( tags = q.order_by(Tag.name.asc()).all()
'artist_gallery.html',
images=images,
artist=artist_name,
has_next=images.has_next,
next_page_url=url_for('main.artist_gallery', artist_name=artist_name, page=images.next_num, per_page=per_page) if images.has_next else '',
has_prev=images.has_prev,
prev_page_url=url_for('main.artist_gallery', artist_name=artist_name, page=images.prev_num, per_page=per_page) if images.has_prev else ''
)
tag_data = []
for t in tags:
imgs = (
ImageRecord.query
.join(ImageRecord.tags)
.filter(Tag.id == t.id)
.order_by(desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at)))
.limit(images_per_tag)
.all()
)
# Display label: for artist/archive use the part after ':', else full name
label = t.name.split(":", 1)[1] if (t.kind in ("artist", "archive") and ":" in t.name) else t.name
tag_data.append({"name": label, "tag": t.name, "images": imgs, "kind": t.kind})
return render_template('tags_list.html',
tag_data=tag_data,
images_per_tag=images_per_tag,
active_kind=kind)
@main.route('/artists') @main.route('/artists')
@login_required @login_required
def artist_list(): def artist_list():
from app.models import ImageRecord # Backward-compatible route that now shows the Tag Explorer filtered to artist tags
from sqlalchemy import func return redirect(url_for('main.tag_list', kind='artist'))
# Define how many images to show per artist card @main.route('/artist/<artist_name>')
images_per_artist = 3 # Change this value to tweak @login_required
def artist_gallery(artist_name):
# Backward-compatible: go to gallery filtered by artist tag
return redirect(url_for('main.gallery', tag=f"artist:{artist_name}"))
# Get all unique artist names
artists = db.session.query(ImageRecord.artist).distinct().all()
artist_data = []
for (artist,) in artists:
images = (
ImageRecord.query
.filter_by(artist=artist)
.order_by(ImageRecord.taken_at.desc().nullslast(), ImageRecord.imported_at.desc())
.limit(images_per_artist)
.all()
)
artist_data.append({'name': artist, 'images': images})
return render_template('artist_list.html', artist_data=artist_data, images_per_artist=images_per_artist)
@main.route('/settings') @main.route('/settings')
@login_required @login_required
@@ -105,6 +129,7 @@ def settings():
abort(403) abort(403)
return render_template('settings.html') return render_template('settings.html')
@main.route('/trigger-thumbnail-generation', methods=['POST']) @main.route('/trigger-thumbnail-generation', methods=['POST'])
@login_required @login_required
def trigger_thumbnail_generation(): def trigger_thumbnail_generation():
@@ -121,6 +146,7 @@ def trigger_thumbnail_generation():
return redirect(url_for('main.settings')) return redirect(url_for('main.settings'))
@main.route('/import-images') @main.route('/import-images')
@login_required @login_required
def trigger_image_import(): def trigger_image_import():
@@ -129,13 +155,93 @@ def trigger_image_import():
flash("Image import triggered.", "success") flash("Image import triggered.", "success")
return redirect(url_for('main.settings')) return redirect(url_for('main.settings'))
@main.route('/reset-db', methods=['POST']) @main.route('/reset-db', methods=['POST'])
@login_required @login_required
def reset_db(): def reset_db():
"""
Safe reset that works with Postgres (TRUNCATE ... CASCADE).
Falls back to ordered deletes if TRUNCATE isn't supported (e.g., SQLite).
"""
if not current_user.is_admin: if not current_user.is_admin:
abort(403) abort(403)
ImageRecord.query.delete() try:
db.session.commit() # Postgres fast path
flash("Database has been reset. All image records removed.", "info") db.session.execute(text("""
TRUNCATE TABLE image_tags, archive_record, image_record, tag
RESTART IDENTITY CASCADE
"""))
db.session.commit()
except Exception:
# Fallback: manual delete order
db.session.execute(image_tags.delete())
ArchiveRecord.query.delete()
ImageRecord.query.delete()
Tag.query.delete()
db.session.commit()
# Optional: clear thumbs on disk to regenerate fresh
shutil.rmtree("/images/thumbs", ignore_errors=True)
os.makedirs("/images/thumbs", exist_ok=True)
flash("Database has been reset. All records removed.", "info")
return redirect(url_for('main.settings')) return redirect(url_for('main.settings'))
# ----------------------------
# Tag add/remove endpoints
# ----------------------------
@main.get("/image/<int:image_id>/tags")
@login_required
def list_tags(image_id):
from app.models import ImageRecord
img = ImageRecord.query.get_or_404(image_id)
return jsonify(ok=True, tags=[{"name": t.name, "kind": t.kind} for t in img.tags])
@main.post("/image/<int:image_id>/tags/add")
@login_required
def add_tag(image_id):
"""
Minimal JSON endpoint to add a tag to an image.
Accepts either 'artist:NAME' / 'archive:...' or a bare freeform tag ('mytag').
"""
name = (request.form.get("name") or "").strip()
if not name:
abort(400)
# Normalize: keep explicit kind prefixes, otherwise treat as user tag
if ":" in name:
kind = name.split(":", 1)[0]
tag_name = name
else:
kind = "user"
tag_name = name
img = ImageRecord.query.get_or_404(image_id)
tag = Tag.query.filter_by(name=tag_name).first()
if not tag:
tag = Tag(name=tag_name, kind=kind)
db.session.add(tag)
db.session.flush()
if tag not in img.tags:
img.tags.append(tag)
db.session.commit()
return jsonify(ok=True, tag={"name": tag.name, "kind": tag.kind})
@main.post("/image/<int:image_id>/tags/remove")
@login_required
def remove_tag(image_id):
"""
Minimal JSON endpoint to remove a tag from an image.
"""
name = (request.form.get("name") or "").strip()
img = ImageRecord.query.get_or_404(image_id)
tag = Tag.query.filter_by(name=name).first()
if tag and tag in img.tags:
img.tags.remove(tag)
db.session.commit()
return jsonify(ok=True)
+30 -12
View File
@@ -5,10 +5,18 @@ from datetime import datetime
# tag to object relationship table # tag to object relationship table
image_tags = db.Table( image_tags = db.Table(
'image_tags', "image_tags",
db.Column('image_id', db.Integer, db.ForeignKey('image_record.id'), primary_key=True), db.Column(
db.Column('tag_id', db.Integer, db.ForeignKey('tag.id'), primary_key=True), "image_id", db.Integer,
db.UniqueConstraint('image_id', 'tag_id', name='uq_image_tag') # prevent duplicate tags per image db.ForeignKey("image_record.id", ondelete="CASCADE"),
primary_key=True,
),
db.Column(
"tag_id", db.Integer,
db.ForeignKey("tag.id", ondelete="CASCADE"),
primary_key=True,
),
db.UniqueConstraint("image_id", "tag_id", name="uq_image_tag"),
) )
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
@@ -19,13 +27,14 @@ class User(UserMixin, db.Model):
is_admin = db.Column(db.Boolean, default=False) is_admin = db.Column(db.Boolean, default=False)
class ImageRecord(db.Model): class ImageRecord(db.Model):
__tablename__ = "image_record"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
filename = db.Column(db.String(255), nullable=False) filename = db.Column(db.String(255), nullable=False)
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(16), nullable=True) # hex of 64-bit hash
artist = db.Column(db.String(255), nullable=True) # artist = db.Column(db.String(255), nullable=True)
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)
@@ -34,22 +43,31 @@ class ImageRecord(db.Model):
taken_at = db.Column(db.DateTime, nullable=True) taken_at = db.Column(db.DateTime, nullable=True)
imported_at = db.Column(db.DateTime, default=db.func.now()) imported_at = db.Column(db.DateTime, default=db.func.now())
is_thumbnail = db.Column(db.Boolean, default=False) is_thumbnail = db.Column(db.Boolean, default=False)
tags = db.relationship('Tag', secondary=image_tags, back_populates='images') tags = db.relationship(
"Tag",
secondary=image_tags,
back_populates="images",
passive_deletes=True, # <-- let DB handle deletes
)
class Tag(db.Model): class Tag(db.Model):
# __tablename__ = "tag" (implicit)
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
# Tag names are unique so we can upsert quickly (e.g. "zip:Artist/ArchiveName.zip")
name = db.Column(db.String(255), unique=True, nullable=False) name = db.Column(db.String(255), unique=True, nullable=False)
# Optional “kind” if you want to filter by tag type later ("zip", "import-source", etc.)
kind = db.Column(db.String(64), nullable=True) kind = db.Column(db.String(64), nullable=True)
images = db.relationship(
images = db.relationship('ImageRecord', secondary=image_tags, back_populates='tags') "ImageRecord",
secondary=image_tags,
back_populates="tags",
passive_deletes=True,
)
class ArchiveRecord(db.Model): class ArchiveRecord(db.Model):
""" """
Tracks archives we've processed so we can skip re-importing the same large file. Tracks archives we've processed so we can skip re-importing the same large file.
Also holds a Tag that marks images that came from this archive. Also holds a Tag that marks images that came from this archive.
""" """
__tablename__ = "archive_record"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
filename = db.Column(db.String(512), nullable=False) # full path or logical name filename = db.Column(db.String(512), nullable=False) # full path or logical name
file_size = db.Column(db.BigInteger, nullable=False) file_size = db.Column(db.BigInteger, nullable=False)
@@ -60,8 +78,8 @@ class ArchiveRecord(db.Model):
artist = db.Column(db.String(255), nullable=True) artist = db.Column(db.String(255), nullable=True)
# Link to the tag we created for this archive so we can reuse it if the same archive returns # Link to the tag we created for this archive so we can reuse it if the same archive returns
tag_id = db.Column(db.Integer, db.ForeignKey('tag.id'), nullable=False) tag_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="SET NULL"), nullable=True)
tag = db.relationship('Tag', backref=db.backref('archives', lazy='dynamic')) tag = db.relationship("Tag")
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
+97
View File
@@ -7,6 +7,12 @@ document.addEventListener('DOMContentLoaded', () => {
const modalClose = document.getElementById('modalClose'); const modalClose = document.getElementById('modalClose');
const modalImageWrapper = document.querySelector('.modal-image-wrapper'); const modalImageWrapper = document.querySelector('.modal-image-wrapper');
// NEW: tag editor elements
const tagEditor = document.getElementById('modalTagEditor');
const tagList = document.getElementById('modalTagList');
const tagForm = document.getElementById('modalTagForm');
const tagInput = tagForm ? tagForm.querySelector('input[name="name"]') : null;
let images = Array.from(document.querySelectorAll('.img-clickable')); let images = Array.from(document.querySelectorAll('.img-clickable'));
let currentIndex = -1; let currentIndex = -1;
let isZoomed = false; let isZoomed = false;
@@ -21,9 +27,93 @@ document.addEventListener('DOMContentLoaded', () => {
const hasPrevPageInput = document.getElementById('hasPrevPage'); const hasPrevPageInput = document.getElementById('hasPrevPage');
const prevPageUrlInput = document.getElementById('prevPageUrl'); const prevPageUrlInput = document.getElementById('prevPageUrl');
// ---------------------------
// Tag editor helpers
// ---------------------------
function setEditorImageId(id) {
if (tagEditor) tagEditor.dataset.imageId = id || '';
}
function getEditorImageId() {
return tagEditor ? tagEditor.dataset.imageId : '';
}
function renderTags(tags) {
if (!tagList) return;
tagList.innerHTML = '';
(tags || []).forEach(t => {
const chip = document.createElement('span');
chip.className = 'tag-chip';
const label = (t.kind === 'artist')
? `🎨 ${t.name.split(':', 1)[0] === 'artist' ? t.name.split(':', 2)[1] : t.name}`
: (t.kind === 'archive')
? `🗜️ ${t.name.split(':', 1)[0] === 'archive' ? t.name.split(':', 2)[1] : t.name}`
: `#${t.name}`;
chip.innerHTML = `${label} <button class="x" data-name="${t.name}" title="Remove">×</button>`;
tagList.appendChild(chip);
});
}
async function loadTags(imageId) {
if (!imageId) { renderTags([]); return; }
try {
const r = await fetch(`/image/${imageId}/tags`);
const j = await r.json();
if (j.ok) renderTags(j.tags);
} catch {
// ignore
}
}
async function addTag(imageId, name) {
const fd = new FormData();
fd.append('name', name);
const r = await fetch(`/image/${imageId}/tags/add`, { method: 'POST', body: fd });
const j = await r.json();
if (j.ok) {
await loadTags(imageId);
return true;
}
return false;
}
async function removeTag(imageId, name) {
const fd = new FormData();
fd.append('name', name);
const r = await fetch(`/image/${imageId}/tags/remove`, { method: 'POST', body: fd });
const j = await r.json();
if (j.ok) {
await loadTags(imageId);
return true;
}
return false;
}
if (tagForm) {
tagForm.addEventListener('submit', async (e) => {
e.preventDefault();
const id = getEditorImageId();
const name = (tagInput.value || '').trim();
if (!id || !name) return;
await addTag(id, name);
tagInput.value = '';
});
}
if (tagList) {
tagList.addEventListener('click', async (e) => {
const btn = e.target.closest('button.x');
if (!btn) return;
const id = getEditorImageId();
const name = btn.dataset.name;
if (!id || !name) return;
await removeTag(id, name);
});
}
// ---------------------------
// Existing viewer logic
// ---------------------------
function updateImage(index) { function updateImage(index) {
const img = images[index]; const img = images[index];
const fileType = img.dataset.type || "image"; const fileType = img.dataset.type || "image";
const imageId = img.dataset.id || "";
// clear wrapper
modalImageWrapper.innerHTML = ""; modalImageWrapper.innerHTML = "";
if (fileType === "video") { if (fileType === "video") {
@@ -45,6 +135,10 @@ document.addEventListener('DOMContentLoaded', () => {
currentIndex = index; currentIndex = index;
resetZoom(); resetZoom();
// NEW: load tags for this image into the modal editor
setEditorImageId(imageId);
loadTags(imageId);
} }
function openModal(index) { function openModal(index) {
@@ -58,6 +152,9 @@ document.addEventListener('DOMContentLoaded', () => {
modalImageWrapper.innerHTML = ''; modalImageWrapper.innerHTML = '';
currentIndex = -1; currentIndex = -1;
resetZoom(); resetZoom();
// clear tag UI
setEditorImageId('');
renderTags([]);
history.replaceState({}, '', window.location.pathname + window.location.search); history.replaceState({}, '', window.location.pathname + window.location.search);
} }
+281 -225
View File
@@ -1,49 +1,136 @@
/* /app/static/style.css */ /* /app/static/style.css
/* ========== General Layout ========== */ ==============================================================================
ImageRepo Global Styles
- Theme tokens
- Base & layout
- Header / Navbar
- Flash messages
- Pagination
- Gallery
- Modal viewer
- Tag overlays & editor
- Tag/Artist cards (tags_list.html)
- Settings
- Index / Landing
- Forms
==============================================================================*/
/*------------------------------------------------------------------------------
Theme tokens
------------------------------------------------------------------------------*/
:root {
/* surfaces */
--bg: #5d6668;
--panel: #1c1c1c;
--panel-alt: rgba(0, 0, 0, .4);
/* nav */
--nav-bg: rgba(30, 30, 30, 0.92);
--nav-border: rgba(255, 255, 255, 0.10);
/* text */
--text: #ffffff;
--text-dim: #e2e2e2;
/* accents */
--link: #d4aeff;
--link-hover: #d4d4d4;
/* shadows */
--shadow-1: 0 2px 6px rgba(0, 0, 0, 0.1);
--shadow-2: 0 3px 4px rgba(0, 0, 0, 0.7);
--shadow-3: 0 4px 10px rgba(0, 0, 0, 0.4);
/* buttons */
--btn-primary: #0066cc;
--btn-primary-hover: #004d99;
--btn-danger: #cc0033;
--btn-danger-hover: #990024;
/* flash */
--flash-info: #3182ce;
--flash-success: #38a169;
--flash-warning: #dd6b20;
--flash-danger: #e53e3e;
/* filter pills (tags_list) */
--filter-bg: #f8fafc; /* slate-50 */
--filter-fg: #0f172a; /* slate-900 */
--filter-border: #e2e8f0; /* slate-200 */
--filter-active-bg: #4f46e5; /* indigo-600 */
--filter-active-fg: #ffffff;
--filter-active-border: #4338ca; /* indigo-700 */
}
/* Optional dark-scheme tuning for filter pills */
@media (prefers-color-scheme: dark) {
:root {
--filter-bg: rgba(255, 255, 255, 0.06);
--filter-fg: #e2e8f0; /* slate-200 */
--filter-border: rgba(255, 255, 255, 0.12);
--filter-active-bg: #6366f1; /* indigo-500 */
--filter-active-fg: #0b1020; /* deeper contrast if desired */
--filter-active-border: #818cf8;
}
}
/*------------------------------------------------------------------------------
Base & layout
------------------------------------------------------------------------------*/
* { box-sizing: border-box; }
body { body {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
margin: 0; margin: 0;
padding: 0 1rem; padding: 0 1rem;
background-color: #5d6668; color: var(--text);
background-color: var(--bg);
} }
.content { padding: 8px; }
.mainview { .mainview {
max-width: 1600px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
background-color: rgba(0,0,0,.4); background-color: var(--panel-alt);
} }
/* Navbar container */
.navbar { /*------------------------------------------------------------------------------
display: flex; Header / Navbar (sticky)
background-color: #1e1e1e; ------------------------------------------------------------------------------*/
border-bottom: 1px solid rgba(255, 255, 255, 0.1); header {
justify-content: flex-start; position: sticky;
flex-wrap: wrap; top: 0;
justify-content: center; z-index: 1000;
}
.navbar {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: .25rem .5rem;
background-color: var(--nav-bg);
border-bottom: 1px solid var(--nav-border);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
} }
/* Button-style nav links */
.nav-button { .nav-button {
display: inline-block; display: inline-block;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
color: white; color: var(--text);
text-decoration: none; text-decoration: none;
border-radius: 5px; border-radius: 5px;
font-weight: bold; font-weight: bold;
font-size: 1.5rem; font-size: 1.5rem;
transition: background-color 0.2s ease, transform 0.1s ease; transition: background-color 0.2s ease, transform 0.1s ease;
} }
.nav-button:hover { background-color: #444; transform: translateY(-1px); }
.nav-button:active { background-color: #1a1a1a; transform: translateY(1px); }
.nav-button:hover { /*------------------------------------------------------------------------------
background-color: #444; Flash messages
transform: translateY(-1px); ------------------------------------------------------------------------------*/
}
.nav-button:active {
background-color: #1a1a1a;
transform: translateY(1px);
}
.flash-container { .flash-container {
max-width: 800px; max-width: 800px;
margin: 1rem auto; margin: 1rem auto;
@@ -56,37 +143,46 @@ body {
border-radius: 6px; border-radius: 6px;
font-weight: 500; font-weight: 500;
color: #fff; color: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-1);
} }
.flash-info { background-color: var(--flash-info); }
.flash-info { .flash-message,
background-color: #3182ce; /* blue */ .flash-success { background-color: var(--flash-success); }
} .flash-warning { background-color: var(--flash-warning); }
.flash-message, .flash-success {
background-color: #38a169; /* green */
}
.flash-warning {
background-color: #dd6b20; /* orange */
}
.flash-danger, .flash-danger,
.flash-error { .flash-error { background-color: var(--flash-danger); }
background-color: #e53e3e; /* red */
/*------------------------------------------------------------------------------
Pagination
------------------------------------------------------------------------------*/
.pagination-container {
text-align: center;
margin: 2rem 0;
} }
.pagination-link {
display: inline-block;
.content { margin: 0 0.3rem;
padding: 8px; padding: 0.5rem 1rem;
border: 1px solid #aaa;
border-radius: 4px;
color: #ffffff;
text-decoration: none;
} }
.pagination-link.active,
.pagination-link:hover {
background-color: #007BFF;
color: white;
border-color: #007BFF;
}
.pagination-ellipsis { padding: 0 0.5rem; color: #999; }
/* ========== Gallery Layout ========== */ /*------------------------------------------------------------------------------
Gallery
------------------------------------------------------------------------------*/
.image-date { .image-date {
display: block; display: block;
font-size: 0.8rem; font-size: 0.8rem;
color: #e2e2e2; color: var(--text-dim);
margin-top: 0.25rem; margin-top: 0.25rem;
} }
@@ -102,16 +198,13 @@ body {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
height: 450px; height: 450px;
background-color: #1c1c1c; background-color: var(--panel);
box-shadow: 0 3px 4px rgba(0,0,0,0.7); box-shadow: var(--shadow-2);
padding: 8px; padding: 8px;
border-radius: 8px; border-radius: 8px;
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
.gallery-item:hover { transform: scale(1.03); }
.gallery-item:hover {
transform: scale(1.03);
}
.gallery-thumb { .gallery-thumb {
position: relative; position: relative;
@@ -125,58 +218,29 @@ body {
} }
.gallery-item a { .gallery-item a {
color: #d4aeff; color: var(--link);
font-weight: bold; font-weight: bold;
text-decoration: none; text-decoration: none;
font-size: 0.95rem; font-size: 0.95rem;
} }
.gallery-item a:hover { text-decoration: underline; color: var(--link-hover); }
.gallery-item a:hover { /* video play glyph sits under tags, above image */
text-decoration: underline;
color: #d4d4d4;
}
.play-overlay { .play-overlay {
position: absolute; position: absolute;
top: 50%; inset: 0;
left: 50%; display: grid;
transform: translate(-50%, -50%); place-items: center;
font-size: 3rem;
color: white; color: white;
text-shadow: 0 0 8px rgba(0, 0, 0, 0.8); font-size: 2rem;
opacity: 1; text-shadow: 0 1px 4px rgba(0,0,0,.6);
z-index: 1;
pointer-events: none; pointer-events: none;
z-index: 2;
} }
.pagination-container { /*------------------------------------------------------------------------------
text-align: center; Modal viewer
margin: 2rem 0; ------------------------------------------------------------------------------*/
}
.pagination-link {
display: inline-block;
margin: 0 0.3rem;
padding: 0.5rem 1rem;
border: 1px solid #aaa;
border-radius: 4px;
color: #ffffff;
text-decoration: none;
}
.pagination-link.active,
.pagination-link:hover {
background-color: #007BFF;
color: white;
border-color: #007BFF;
}
.pagination-ellipsis {
padding: 0 0.5rem;
color: #999;
}
/* MODAL STYLES */
.modal { .modal {
display: none; display: none;
position: fixed; position: fixed;
@@ -186,10 +250,7 @@ body {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.modal.active { display: flex; }
.modal.active {
display: flex;
}
.modal-content { .modal-content {
position: relative; position: relative;
@@ -201,7 +262,6 @@ body {
align-items: center; align-items: center;
} }
/* Modal body */ /* Modal body */
.modal-body { .modal-body {
display: flex; display: flex;
@@ -211,27 +271,17 @@ body {
width: 100%; width: 100%;
} }
/* Image wrapper for tall image logic */ /* Image wrapper & zoom */
.modal-image-wrapper { .modal-image-wrapper {
max-width: 100%; max-width: 100%;
max-height: 80vh; max-height: 80vh;
overflow: hidden; overflow: hidden;
cursor: default; cursor: default;
} }
.modal-image-wrapper.zoomed { overflow: auto; cursor: grab; }
.modal-image-wrapper.zoomed {
overflow: auto;
cursor: grab;
}
.modal-image-wrapper.zoomed img { .modal-image-wrapper.zoomed img {
width: auto; width: auto; height: auto; max-width: none; max-height: none;
height: auto;
max-width: none;
max-height: none;
} }
.modal-image-wrapper img { .modal-image-wrapper img {
max-width: 100%; max-width: 100%;
max-height: 80vh; max-height: 80vh;
@@ -239,19 +289,13 @@ body {
border-radius: 6px; border-radius: 6px;
box-shadow: 0 0 10px rgba(0,0,0,0.6); box-shadow: 0 0 10px rgba(0,0,0,0.6);
} }
/* Tall images */
/* Adjust for very tall images */ .modal-image-wrapper.too-tall { overflow-y: auto; }
.modal-image-wrapper.too-tall {
overflow-y: auto;
}
.modal-image-wrapper.too-tall img { .modal-image-wrapper.too-tall img {
width: 90vw; width: 90vw; height: auto; max-height: none;
height: auto;
max-height: none;
} }
/* Modal buttons */ /* Modal navigation buttons */
.modal-button { .modal-button {
position: absolute; position: absolute;
top: 10%; top: 10%;
@@ -270,20 +314,52 @@ body {
transition: opacity 0.2s; transition: opacity 0.2s;
border-radius: 4px; border-radius: 4px;
} }
.modal-content:hover .modal-button { opacity: 1; }
.modal-prev { left: 0; }
.modal-next { right: 0; }
.modal-content:hover .modal-button { /*------------------------------------------------------------------------------
opacity: 1; Tag overlays & editor
------------------------------------------------------------------------------*/
/* Pills used inside overlays and editors */
.tags {
margin-top: .4rem;
display: flex;
flex-wrap: wrap;
gap: .25rem;
} }
.modal-prev { .tag-chip {
left: 0; font: 12px/1.6 system-ui, sans-serif;
padding: 2px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: #334155;
text-decoration: none;
border: 1px solid rgba(199, 210, 254, 0.9);
white-space: nowrap;
}
.tag-chip:hover { background: rgba(255, 255, 255, 1); }
.tag-editor { margin-top: .5rem; }
.tag-form { margin-top: .4rem; display: flex; gap: .5rem; }
.tag-form input { flex: 1; }
/* Tag overlay bar at the top of the image */
.tag-overlay {
position: absolute;
top: 0; left: 0; right: 0;
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px;
z-index: 2;
background: linear-gradient(to bottom, rgba(0,0,0,0.45), rgba(0,0,0,0));
} }
.modal-next { /*------------------------------------------------------------------------------
right: 0; Tag/Artist cards (tags_list.html)
} ------------------------------------------------------------------------------*/
/* Artist Styling */
.artist-grid { .artist-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
@@ -291,17 +367,28 @@ body {
padding: 1rem; padding: 1rem;
} }
.artist-card-link {
text-decoration: none;
color: inherit;
display: block;
transition: transform 0.2s ease;
}
.artist-card-link:hover .artist-card {
transform: scale(1.02);
box-shadow: var(--shadow-3);
}
.artist-card { .artist-card {
background-color: #1c1c1c; background-color: var(--panel);
padding: 1rem; padding: 1rem;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 6px rgba(0,0,0,0.2); box-shadow: 0 2px 6px rgba(0,0,0,0.2);
transition: transform 0.2s ease;
} }
.artist-card h3 { .artist-card h3 {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
text-align: center; text-align: center;
color: white; color: var(--text);
} }
.artist-preview { .artist-preview {
@@ -311,7 +398,6 @@ body {
overflow: hidden; overflow: hidden;
border-radius: 6px; border-radius: 6px;
} }
.artist-thumb { .artist-thumb {
flex: 1; flex: 1;
height: 300px; height: 300px;
@@ -320,37 +406,54 @@ body {
background-repeat: no-repeat; background-repeat: no-repeat;
transition: transform 0.3s ease; transition: transform 0.3s ease;
} }
.artist-thumb:hover { transform: scale(1.05); }
.artist-thumb:hover { /* Filter pills (top of tags_list) */
transform: scale(1.05); .filter-bar {
display: flex;
flex-wrap: wrap;
gap: .5rem;
margin: .75rem 0 1rem;
} }
.filter-btn {
.artist-card-link { display: inline-flex;
align-items: center;
gap: .4rem;
padding: .4rem .8rem;
border-radius: 999px;
border: 1px solid var(--filter-border);
background: var(--filter-bg);
color: var(--filter-fg);
text-decoration: none; text-decoration: none;
color: inherit; font: 14px/1.2 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: block; transition: transform .15s ease, box-shadow .15s ease, background .15s ease;
transition: transform 0.2s ease; }
} .filter-btn:hover {
transform: translateY(-1px);
.artist-card-link:hover .artist-card { box-shadow: 0 4px 12px rgba(0,0,0,.08);
transform: scale(1.02); }
box-shadow: 0 4px 10px rgba(0,0,0,0.4); .filter-btn.is-active {
background: var(--filter-active-bg);
color: var(--filter-active-fg);
border-color: var(--filter-active-border);
}
.filter-btn:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 2px;
} }
/*------------------------------------------------------------------------------
Settings
------------------------------------------------------------------------------*/
.settings-container { .settings-container {
max-width: 600px; max-width: 600px;
margin: 2rem auto; margin: 2rem auto;
padding: 1.5rem; padding: 1.5rem;
background: #f8f8f8; background: #f8f8f8;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1); box-shadow: var(--shadow-1);
} }
.settings-section h2 { margin-bottom: 1rem; text-align: center; }
.settings-section h2 {
margin-bottom: 1rem;
text-align: center;
}
.settings-actions { .settings-actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -366,24 +469,10 @@ body {
cursor: pointer; cursor: pointer;
transition: background 0.3s ease; transition: background 0.3s ease;
} }
.primary-btn { background-color: var(--btn-primary); color: #fff; }
.primary-btn { .primary-btn:hover { background-color: var(--btn-primary-hover); }
background-color: #0066cc; .danger-btn { background-color: var(--btn-danger); color: #fff; }
color: white; .danger-btn:hover { background-color: var(--btn-danger-hover); }
}
.primary-btn:hover {
background-color: #004d99;
}
.danger-btn {
background-color: #cc0033;
color: white;
}
.danger-btn:hover {
background-color: #990024;
}
.settings-info { .settings-info {
margin-top: 2rem; margin-top: 2rem;
@@ -392,21 +481,20 @@ body {
text-align: center; text-align: center;
} }
/*------------------------------------------------------------------------------
Index / Landing
------------------------------------------------------------------------------*/
.index-hero { .index-hero {
position: relative; position: relative;
height: 100vh; height: 100vh;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
filter: blur(0px); /* Required to initialize */ filter: blur(0px); /* initialize */
overflow: hidden; overflow: hidden;
} }
.index-overlay { .index-overlay {
position: absolute; position: absolute;
top: 0; inset: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(20, 20, 20, 0.5); background-color: rgba(20, 20, 20, 0.5);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
display: flex; display: flex;
@@ -414,60 +502,35 @@ body {
justify-content: center; justify-content: center;
z-index: 1; z-index: 1;
} }
.index-message { .index-message {
text-align: center; text-align: center;
color: #fff; color: #fff;
padding: 2rem; padding: 2rem;
max-width: 600px; max-width: 600px;
} }
.index-message h1 { font-size: 2.5rem; margin-bottom: 1rem; }
.index-message p { font-size: 1.2rem; margin-bottom: 2rem; }
.index-message .btn { font-size: 1.1rem; }
.index-message h1 { /*------------------------------------------------------------------------------
font-size: 2.5rem; Forms
margin-bottom: 1rem; ------------------------------------------------------------------------------*/
}
.index-message p {
font-size: 1.2rem;
margin-bottom: 2rem;
}
.index-message .btn {
font-size: 1.1rem;
}
/* Form Styling */
.form-container { .form-container {
max-width: 400px; max-width: 400px;
margin: 2rem auto; margin: 2rem auto;
padding: 2rem; padding: 2rem;
background-color: #ffffffdd; background-color: #ffffffdd;
border-radius: 10px; border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-1);
} }
.form-card { display: flex; flex-direction: column; gap: 1rem; }
.form-card { .form-group { display: flex; flex-direction: column; }
display: flex; .form-label { margin-bottom: 0.5rem; font-weight: 600; }
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-label {
margin-bottom: 0.5rem;
font-weight: 600;
}
.form-input { .form-input {
padding: 0.6rem; padding: 0.6rem;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 5px; border-radius: 5px;
} }
.form-button { .form-button {
padding: 0.75rem; padding: 0.75rem;
background-color: #5a67d8; background-color: #5a67d8;
@@ -476,12 +539,5 @@ body {
border-radius: 5px; border-radius: 5px;
cursor: pointer; cursor: pointer;
} }
.form-button:hover { background-color: #434190; }
.form-button:hover { .form-footer-text { margin-top: 1rem; text-align: center; }
background-color: #434190;
}
.form-footer-text {
margin-top: 1rem;
text-align: center;
}
+37 -5
View File
@@ -3,21 +3,44 @@
{% for image in images.items %} {% for image in images.items %}
<div class="gallery-item"> <div class="gallery-item">
<div class="gallery-thumb img-clickable" <div class="gallery-thumb img-clickable"
data-full="{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '') ) }}" data-id="{{ image.id }}"
data-type="{{ 'video' if image.filename.lower().endswith(('.mp4', '.mov')) else 'image' }}" data-full="{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '') ) }}"
style="background-image: url('{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}');" data-type="{{ 'video' if image.filename.lower().endswith(('.mp4', '.mov')) else 'image' }}"
data-filename="{{ image.filename }}"> style="background-image: url('{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}');"
data-filename="{{ image.filename }}">
{% if image.tags %}
<div class="tag-overlay">
{% for t in image.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] }}
{% elif t.kind == 'archive' %}
🗜️ {{ t.name.split(':', 1)[1] }}
{% else %}
#{{ t.name }}
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
{% if image.filename.lower().endswith(('.mp4', '.mov')) %} {% if image.filename.lower().endswith(('.mp4', '.mov')) %}
<div class="play-overlay"></div> <div class="play-overlay"></div>
{% endif %} {% endif %}
</div> </div>
<a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a> <!-- Removed the artist link; keep date only -->
<span class="image-date">{{ image.taken_at or image.imported_at | datetimeformat }}</span> <span class="image-date">{{ image.taken_at or image.imported_at | datetimeformat }}</span>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<!-- Modal --> <!-- Modal -->
<div id="imageModal" class="modal"> <div id="imageModal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -29,6 +52,15 @@
<div id="modalImageWrapper" class="modal-image-wrapper"> <div id="modalImageWrapper" class="modal-image-wrapper">
<img id="modalImage" src="" alt="Full View"> <img id="modalImage" src="" alt="Full View">
</div> </div>
<!-- Tag editor (inline, minimal UI) -->
<div id="modalTagEditor" class="tag-editor" data-image-id="">
<div id="modalTagList" class="tags"></div>
<form id="modalTagForm" class="tag-form" autocomplete="off">
<input type="text" name="name" placeholder="Add tag… (artist:foo, archive:bar, or freeform)">
<button type="submit">Add</button>
</form>
</div>
</div> </div>
</div> </div>
</div> </div>
+3 -4
View File
@@ -8,7 +8,7 @@
<body> <body>
<div class="mainview"> <div class="mainview">
<header>
<header> <header>
<nav class="navbar"> <nav class="navbar">
<a class="nav-button" href="{{ url_for('main.gallery') if current_user.is_authenticated else url_for('main.index') }}">Home</a> <a class="nav-button" href="{{ url_for('main.gallery') if current_user.is_authenticated else url_for('main.index') }}">Home</a>
@@ -18,7 +18,8 @@
{% endif %} {% endif %}
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<a class="nav-button" href="{{ url_for('main.artist_list') }}">Artists</a> <a class="nav-button" href="{{ url_for('main.tag_list', kind='artist') }}">Artists</a>
<a class="nav-button" href="{{ url_for('main.tag_list') }}">Tags</a>
<a class="nav-button" href="{{ url_for('auth.logout') }}">Logout</a> <a class="nav-button" href="{{ url_for('auth.logout') }}">Logout</a>
{% else %} {% else %}
<a class="nav-button" href="{{ url_for('auth.login') }}">Login</a> <a class="nav-button" href="{{ url_for('auth.login') }}">Login</a>
@@ -27,8 +28,6 @@
</nav> </nav>
</header> </header>
</header>
<!-- FLASH MESSAGES --> <!-- FLASH MESSAGES -->
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
+46
View File
@@ -0,0 +1,46 @@
{% extends "layout.html" %}
{% block title %}{{ 'Artists' if active_kind == 'artist' else 'Tags' }}{% endblock %}
{% block content %}
<h1>{{ 'Artists' if active_kind == 'artist' else 'Tags' }}</h1>
<!-- Small kind filter nav (optional) -->
<!-- Tag kind filter -->
<div class="filter-bar" role="tablist" aria-label="Filter tags by kind">
<a class="filter-btn {{ 'is-active' if not active_kind }}"
href="{{ url_for('main.tag_list') }}"
aria-current="{{ 'page' if not active_kind else 'false' }}">🏷️ All</a>
<a class="filter-btn {{ 'is-active' if active_kind=='artist' }}"
href="{{ url_for('main.tag_list', kind='artist') }}"
aria-current="{{ 'page' if active_kind=='artist' else 'false' }}">🎨 Artists</a>
<a class="filter-btn {{ 'is-active' if active_kind=='archive' }}"
href="{{ url_for('main.tag_list', kind='archive') }}"
aria-current="{{ 'page' if active_kind=='archive' else 'false' }}">🗜️ Archives</a>
<a class="filter-btn {{ 'is-active' if active_kind=='user' }}"
href="{{ url_for('main.tag_list', kind='user') }}"
aria-current="{{ 'page' if active_kind=='user' else 'false' }}"># User</a>
</div>
<div class="artist-grid">
{% for tag in tag_data %}
<a href="{{ url_for('main.gallery', tag=tag.tag) }}" class="artist-card-link">
<div class="artist-card">
<h3>
{% if tag.kind == 'artist' %}🎨{% elif tag.kind == 'archive' %}🗜️{% else %}# {% endif %}
{{ tag.name }}
</h3>
<div class="artist-preview">
{% for image in tag.images %}
<div class="artist-thumb"
style="background-image: url('{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}');">
</div>
{% endfor %}
</div>
</div>
</a>
{% endfor %}
</div>
{% endblock %}
+103 -51
View File
@@ -241,37 +241,33 @@ def import_images_task(source_dir: str, dest_dir: str) -> str:
return summary return summary
def process_archive(archive_path: str, dest_dir: str, artist: str, existing_phashes) -> int: def process_archive(archive_path, dest_dir, artist, existing_phashes):
""" """
Extract archive into a temp dir, import/tag media, and record ArchiveRecord for de-dup. Extracts archive to temp dir, imports/tag existing images, and records ArchiveRecord.
Creates an archive:* tag ONLY if at least one image was imported or tagged.
Returns number of items imported or tagged. Returns number of items imported or tagged.
""" """
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)
# De-dup the archive itself # Skip if archive already processed
existing_archive = ArchiveRecord.query.filter_by(hash=archive_hash).first() existing_archive = ArchiveRecord.query.filter_by(hash=archive_hash).first()
if existing_archive: if existing_archive:
print(f"[SKIP] Archive already imported: {archive_path}") print(f"[SKIP] Archive already imported: {archive_path}")
return 0 return 0
# Create/reuse tag for this archive
tag_name = f"archive:{artist}/{os.path.basename(archive_path)}" tag_name = f"archive:{artist}/{os.path.basename(archive_path)}"
tag = Tag.query.filter_by(name=tag_name).first() tmpdir = tempfile.mkdtemp(prefix="extract_")
if not tag:
tag = Tag(name=tag_name, kind="archive")
db.session.add(tag)
db.session.flush() # ensure tag.id
# Create an extraction dir under the configured temp base touched_records = [] # ImageRecord objects we imported or matched
tmpdir = tempfile.mkdtemp(prefix="extract_", dir=str(get_tmp_base()))
imported_or_tagged = 0 imported_or_tagged = 0
created_tag = None
try: try:
# Resilient extraction (unar → fallback 7z for RAR; 7z → fallback unar for others) # Extract using pyunpack
extract_archive_resilient(archive_path, tmpdir) Archive(archive_path).extractall(tmpdir)
# Walk extracted tree and process media files # Import / collect all media
for root, _, files in os.walk(tmpdir): for root, _, files in os.walk(tmpdir):
for file in files: for file in files:
if not file.lower().endswith(ALLOWED_MEDIA_EXTS): if not file.lower().endswith(ALLOWED_MEDIA_EXTS):
@@ -281,24 +277,41 @@ def process_archive(archive_path: str, dest_dir: str, artist: str, existing_phas
added, _phash, rec = import_single_file( added, _phash, rec = import_single_file(
src_path=src_path, src_path=src_path,
dest_dir=dest_dir, dest_dir=dest_dir,
artist=artist, artist=artist, # still used for auto artist:<name> tag
existing_phashes=existing_phashes, existing_phashes=existing_phashes,
commit=False # batch after loop commit=False
) )
if rec is not None and tag not in rec.tags: if rec is not None:
rec.tags.append(tag) touched_records.append(rec)
imported_or_tagged += 1 if added:
imported_or_tagged += 1
# Record this archive as processed # Only now: create/attach the archive tag if we actually touched images
if touched_records:
created_tag = Tag.query.filter_by(name=tag_name).first()
if not created_tag:
created_tag = Tag(name=tag_name, kind='archive')
db.session.add(created_tag)
db.session.flush()
newly_tagged = 0
for rec in touched_records:
if created_tag not in rec.tags:
rec.tags.append(created_tag)
newly_tagged += 1
imported_or_tagged += newly_tagged
# Record this archive as processed.
arch = ArchiveRecord( arch = ArchiveRecord(
filename=archive_path, filename=archive_path,
file_size=archive_size, file_size=archive_size,
hash=archive_hash, hash=archive_hash,
artist=artist, artist=artist,
tag=tag tag=created_tag # None if we had no media
) )
db.session.add(arch) db.session.add(arch)
db.session.commit() db.session.commit()
print(f"[INFO] Archive processed: {archive_path} items={imported_or_tagged}") print(f"[INFO] Archive processed: {archive_path} items={imported_or_tagged}")
return imported_or_tagged return imported_or_tagged
@@ -307,69 +320,85 @@ def process_archive(archive_path: str, dest_dir: str, artist: str, existing_phas
# ============================================================================= # =============================================================================
# Core: Single-file import path # Core: Single-file import path (content-addressed storage)
# ============================================================================= # =============================================================================
def build_hashed_dest_path(target_dir: str, original_name: str, content_hash: str) -> str:
"""
Always produce a content-addressed destination:
<target>/<name_without_ext>__<hash[:10]><ext>
If that path already exists, append a numeric suffix.
"""
base, ext = os.path.splitext(original_name)
candidate = os.path.join(target_dir, f"{base}__{content_hash[:10]}{ext}")
if not os.path.exists(candidate):
return candidate
# Rare: collision (e.g., concurrent import) → add counter
i = 2
while True:
candidate = os.path.join(target_dir, f"{base}__{content_hash[:10]}_{i}{ext}")
if not os.path.exists(candidate):
return candidate
i += 1
def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phashes, commit: bool = True): def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phashes, commit: 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). Returns (added: bool, phash_or_None, record_or_None).
""" """
filename = os.path.basename(src_path) original_name = os.path.basename(src_path)
print(f"[INFO] Processing: {src_path}") print(f"[INFO] Processing: {src_path}")
file_size = os.path.getsize(src_path) file_size = os.path.getsize(src_path)
# Check by name + size # Compute content hash first (authoritative de-dup + used in filepath)
existing = ImageRecord.query.filter_by(filename=filename, file_size=file_size).first()
if existing:
print(f"[SKIP] Duplicate by name+size: {filename}")
return (False, None, existing)
# Check by content hash
file_hash = calculate_hash(src_path) file_hash = calculate_hash(src_path)
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: {filename}") print(f"[SKIP] Duplicate by hash: {original_name}")
return (False, None, existing) return (False, None, existing)
# 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()
if existing_ns:
print(f"[INFO] Same name+size exists but different hash; storing with hashed name: {original_name}")
# Metadata & pHash similarity # Metadata & pHash similarity
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 {filename}") print(f"[SKIP] Missing dimension data for {original_name}")
return (False, None, None) return (False, None, None)
phash = calculate_perceptual_hash(src_path) phash = calculate_perceptual_hash(src_path)
if phash and is_similar_image(phash, metadata["width"], metadata["height"], existing_phashes): if phash and is_similar_image(phash, metadata["width"], metadata["height"], existing_phashes):
print(f"[SKIP] {filename} is visually similar to an existing larger image.") print(f"[SKIP] {original_name} is visually similar to an existing larger image.")
return (False, phash, None) return (False, phash, None)
# Copy into /images/<artist>/<filename> # Copy into /images/<artist>/<base>__<hash[:10]><ext>
target_dir = os.path.join(dest_dir, artist) target_dir = os.path.join(dest_dir, artist)
os.makedirs(target_dir, exist_ok=True) os.makedirs(target_dir, exist_ok=True)
dest_path = os.path.join(target_dir, filename) dest_path = build_hashed_dest_path(target_dir, original_name, file_hash)
shutil.copy2(src_path, dest_path) shutil.copy2(src_path, dest_path)
# Thumbnail # Thumbnails (mirrored for both images and videos)
try: try:
if dest_path.lower().endswith((".mp4", ".mov")): if dest_path.lower().endswith((".mp4", ".mov")):
thumb_filename = f"{uuid.uuid4().hex}.jpg" thumb_path = generate_video_thumbnail_mirrored(dest_path)
thumb_path = os.path.join(dest_dir, "thumbs", thumb_filename)
thumb_path = generate_video_thumbnail(dest_path, thumb_path)
else: else:
thumb_path = generate_thumbnail(dest_path) thumb_path = generate_thumbnail(dest_path)
except Exception as e: except Exception as e:
print(f"[WARN] Failed to generate thumbnail for {filename}: {e}") print(f"[WARN] Failed to generate thumbnail for {original_name}: {e}")
thumb_path = None thumb_path = None
# Create DB record # Create DB record (keep display name as original; filepath is content-addressed)
record = ImageRecord( record = ImageRecord(
filename=filename, filename=original_name,
filepath=dest_path, filepath=dest_path,
thumb_path=thumb_path, thumb_path=thumb_path,
hash=file_hash, hash=file_hash,
perceptual_hash=str(phash) if phash else None, perceptual_hash=str(phash) if phash else None,
artist=artist,
file_size=metadata["file_size"], file_size=metadata["file_size"],
width=metadata["width"], width=metadata["width"],
height=metadata["height"], height=metadata["height"],
@@ -378,6 +407,19 @@ def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phash
taken_at=metadata["taken_at"], taken_at=metadata["taken_at"],
imported_at=datetime.utcnow() imported_at=datetime.utcnow()
) )
# Auto-tag with artist:<name> (treat artist as a tag only)
if artist:
artist_tag_name = f"artist:{artist}"
artist_tag = Tag.query.filter_by(name=artist_tag_name).first()
if not artist_tag:
artist_tag = Tag(name=artist_tag_name, kind="artist")
db.session.add(artist_tag)
db.session.flush()
if artist_tag not in record.tags:
record.tags.append(artist_tag)
db.session.add(record) db.session.add(record)
if commit: if commit:
db.session.commit() db.session.commit()
@@ -390,13 +432,15 @@ def import_single_file(src_path: str, dest_dir: str, artist: str, existing_phash
def generate_thumbnail(image_path: str, size: tuple[int, int] = (400, 400), overwrite: bool = False) -> str: def generate_thumbnail(image_path: str, size: tuple[int, int] = (400, 400), overwrite: bool = False) -> str:
""" """
Save thumbnails mirrored under /images/thumbs/<artist>/<filename>. Save thumbnails mirrored under /images/thumbs/<artist>/<unique_filename>.
(unique_filename includes the hash suffix, so no collisions)
""" """
images_root = Path("/images").resolve() images_root = Path("/images").resolve()
image_path = Path(image_path).resolve() image_path = Path(image_path).resolve()
rel_path = image_path.relative_to(images_root) # raises if not under /images rel_path = image_path.relative_to(images_root) # raises if not under /images
thumb_path = images_root / "thumbs" / rel_path thumb_path = images_root / "thumbs" / rel_path
thumb_path = thumb_path.with_suffix(".jpg") if thumb_path.suffix.lower() not in (".jpg", ".jpeg") else thumb_path
thumb_path.parent.mkdir(parents=True, exist_ok=True) thumb_path.parent.mkdir(parents=True, exist_ok=True)
if not overwrite and thumb_path.exists(): if not overwrite and thumb_path.exists():
@@ -409,15 +453,23 @@ def generate_thumbnail(image_path: str, size: tuple[int, int] = (400, 400), over
return str(thumb_path) return str(thumb_path)
def generate_video_thumbnail(video_path: str, output_path: str, time_position: str = "00:00:01") -> str | None: def generate_video_thumbnail_mirrored(video_path: str, time_position: str = "00:00:01") -> str | None:
os.makedirs(os.path.dirname(output_path), exist_ok=True) """
command = [ Mirror the stored (hashed) video path under /images/thumbs/... and save as .jpg.
"ffmpeg", "-i", video_path, "-ss", time_position, """
"-vframes", "1", "-vf", f"scale={THUMB_SIZE[0]}:-1", output_path images_root = Path("/images").resolve()
video_path = Path(video_path).resolve()
rel_path = video_path.relative_to(images_root) # raises if not under /images
thumb_path = (images_root / "thumbs" / rel_path).with_suffix(".jpg")
thumb_path.parent.mkdir(parents=True, exist_ok=True)
cmd = [
"ffmpeg", "-i", str(video_path), "-ss", time_position,
"-vframes", "1", "-vf", f"scale={THUMB_SIZE[0]}:-1", str(thumb_path)
] ]
try: try:
subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return output_path return str(thumb_path)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
print(f"[WARN] Failed to extract video thumbnail for {video_path}: {e}") print(f"[WARN] Failed to extract video thumbnail for {video_path}: {e}")
return None return None
@@ -0,0 +1,138 @@
"""Add ON DELETE CASCADE to image_tags; SET NULL for ArchiveRecord.tag_id
Revision ID: 486bf4238616
Revises: fe4dfdd6616c
Create Date: 2025-08-14 22:41:40.746201
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '486bf4238616'
down_revision = 'fe4dfdd6616c'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = "486bf4238616"
down_revision = "fe4dfdd6616c"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
dialect = bind.dialect.name
if dialect == "sqlite":
# --- image_tags: recreate with CASCADE FKs ---
op.create_table(
"image_tags_new",
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"),
)
op.execute("INSERT INTO image_tags_new (image_id, tag_id) SELECT image_id, tag_id FROM image_tags")
op.drop_table("image_tags")
op.rename_table("image_tags_new", "image_tags")
# --- archive_record.tag_id: leave FK as-is on SQLite (dev) ---
# We do not alter ondelete here due to SQLite ALTER limitations.
# Your reset route deletes in safe order, so this is fine for testing.
else:
# --- Postgres path: drop/recreate FKs normally ---
# image_tags -> ON DELETE CASCADE
op.drop_constraint("image_tags_image_id_fkey", "image_tags", type_="foreignkey")
op.drop_constraint("image_tags_tag_id_fkey", "image_tags", type_="foreignkey")
op.create_foreign_key(
"image_tags_image_id_fkey",
"image_tags",
"image_record",
["image_id"],
["id"],
ondelete="CASCADE",
)
op.create_foreign_key(
"image_tags_tag_id_fkey",
"image_tags",
"tag",
["tag_id"],
["id"],
ondelete="CASCADE",
)
# archive_record.tag_id -> NULLable + ON DELETE SET NULL
op.alter_column("archive_record", "tag_id", existing_type=sa.Integer(), nullable=True)
op.drop_constraint("archive_record_tag_id_fkey", "archive_record", type_="foreignkey")
op.create_foreign_key(
"archive_record_tag_id_fkey",
"archive_record",
"tag",
["tag_id"],
["id"],
ondelete="SET NULL",
)
def downgrade():
bind = op.get_bind()
dialect = bind.dialect.name
if dialect == "sqlite":
# revert image_tags to non-cascade FKs (recreate again)
op.create_table(
"image_tags_old",
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"]),
sa.ForeignKeyConstraint(["tag_id"], ["tag.id"]),
sa.UniqueConstraint("image_id", "tag_id", name="uq_image_tag"),
)
op.execute("INSERT INTO image_tags_old (image_id, tag_id) SELECT image_id, tag_id FROM image_tags")
op.drop_table("image_tags")
op.rename_table("image_tags_old", "image_tags")
# archive_record left unchanged in upgrade, nothing to revert here.
else:
# Postgres: revert to no-cascade FKs and non-nullable tag_id
op.drop_constraint("archive_record_tag_id_fkey", "archive_record", type_="foreignkey")
op.create_foreign_key(
"archive_record_tag_id_fkey",
"archive_record",
"tag",
["tag_id"],
["id"],
)
op.alter_column("archive_record", "tag_id", existing_type=sa.Integer(), nullable=False)
op.drop_constraint("image_tags_tag_id_fkey", "image_tags", type_="foreignkey")
op.drop_constraint("image_tags_image_id_fkey", "image_tags", type_="foreignkey")
op.create_foreign_key(
"image_tags_image_id_fkey",
"image_tags",
"image_record",
["image_id"],
["id"],
)
op.create_foreign_key(
"image_tags_tag_id_fkey",
"image_tags",
"tag",
["tag_id"],
["id"],
)
@@ -0,0 +1,39 @@
"""Drop ImageRecord.artist (use tags instead)
Revision ID: a46e641430b8
Revises: 486bf4238616
Create Date: 2025-08-15 00:52:13.293364
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a46e641430b8'
down_revision = '486bf4238616'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
dialect = bind.dialect.name
if dialect == "sqlite":
# Recreate table without the 'artist' column (SQLite can't drop columns natively)
with op.batch_alter_table("image_record", recreate="always") as batch_op:
batch_op.drop_column("artist")
else:
# Postgres / others can drop directly
op.drop_column("image_record", "artist")
def downgrade():
bind = op.get_bind()
dialect = bind.dialect.name
if dialect == "sqlite":
with op.batch_alter_table("image_record", recreate="always") as batch_op:
batch_op.add_column(sa.Column("artist", sa.String(length=255), nullable=True))
else:
op.add_column("image_record", sa.Column("artist", sa.String(length=255), nullable=True))