diff --git a/.dockerignore b/.dockerignore
index 287cf79..13f7d8e 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,28 +1,28 @@
-# Ignore test data
-images/
-import/
-
-# Ignore Python cache
-__pycache__/
-*.pyc
-*.pyo
-*.pyd
-
-# Ignore environment files
-.env
-.env.*
-
-# Ignore virtual environments
-env/
-venv/
-
-# Ignore Git files
-.git
-.gitignore
-
-# Ignore DB files
-imagerepo/
-
-# Ignore Docker related files
-.dockerignore
+# Ignore test data
+images/
+import/
+
+# Ignore Python cache
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+
+# Ignore environment files
+.env
+.env.*
+
+# Ignore virtual environments
+env/
+venv/
+
+# Ignore Git files
+.git
+.gitignore
+
+# Ignore DB files
+imagerepo/
+
+# Ignore Docker related files
+.dockerignore
docker-compose.yml
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 6eb5949..283b2dd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,13 +1,16 @@
-# Ignore imported and generated image folders
-images/
-import/
-imagerepo/
-
-# Optional: Ignore SQLite DB, Python cache, virtual env, etc.
-*.db
-__pycache__/
-*.pyc
-*.pyo
-*.pyd
-env/
-venv/
+# Ignore imported and generated image folders
+images/
+import/
+imagerepo/
+
+# Optional: Ignore SQLite DB, Python cache, virtual env, etc.
+*.db
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+env/
+venv/
+
+# Claude files
+.claude/
\ No newline at end of file
diff --git a/app/main.py b/app/main.py
index 6cc0c3b..1c156a7 100644
--- a/app/main.py
+++ b/app/main.py
@@ -423,7 +423,7 @@ def gallery_jump():
)
-def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3):
+def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3, search=None):
"""
Helper function to get tags with counts and preview images.
Returns (tag_data list, total_count, has_more).
@@ -436,6 +436,8 @@ def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3):
count_q = db.session.query(func.count(Tag.id))
if kind:
count_q = count_q.filter(Tag.kind == kind)
+ if search:
+ count_q = count_q.filter(Tag.name.ilike(f'%{search}%'))
total_count = count_q.scalar()
# Query 1: Get tags with image counts (paginated)
@@ -446,6 +448,8 @@ def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3):
if kind:
q = q.filter(Tag.kind == kind)
+ if search:
+ q = q.filter(Tag.name.ilike(f'%{search}%'))
tags_with_counts = q.order_by(Tag.name.asc()).offset(offset).limit(limit).all()
@@ -505,22 +509,25 @@ def tag_list():
Generic tag explorer with infinite scroll:
/tags -> all tags
/tags?kind=user -> only user tags
+ /tags?search=xyz -> tags matching search term
etc.
Shows a few preview images per tag.
Initially loads 35 tags, more loaded via API as user scrolls.
"""
kind = request.args.get('kind')
+ search = request.args.get('search', '').strip() or None
limit = 35
images_per_tag = 3
tag_data, total_count, has_more = _get_tags_with_previews(
- kind=kind, limit=limit, offset=0, images_per_tag=images_per_tag
+ kind=kind, limit=limit, offset=0, images_per_tag=images_per_tag, search=search
)
return render_template('tags_list.html',
tag_data=tag_data,
images_per_tag=images_per_tag,
active_kind=kind,
+ search_query=search or '',
total_count=total_count,
has_more=has_more,
page_size=limit)
@@ -533,12 +540,13 @@ def api_tags_list():
Returns JSON with tag cards HTML and pagination info.
"""
kind = request.args.get('kind')
+ search = request.args.get('search', '').strip() or None
offset = request.args.get('offset', 0, type=int)
limit = request.args.get('limit', 35, type=int)
images_per_tag = 3
tag_data, total_count, has_more = _get_tags_with_previews(
- kind=kind, limit=limit, offset=offset, images_per_tag=images_per_tag
+ kind=kind, limit=limit, offset=offset, images_per_tag=images_per_tag, search=search
)
# Render tag cards to HTML
diff --git a/app/static/apple-touch-icon.png b/app/static/apple-touch-icon.png
new file mode 100644
index 0000000..840e451
Binary files /dev/null and b/app/static/apple-touch-icon.png differ
diff --git a/app/static/favicon-16x16.png b/app/static/favicon-16x16.png
new file mode 100644
index 0000000..ae6e2b1
Binary files /dev/null and b/app/static/favicon-16x16.png differ
diff --git a/app/static/favicon-180x180.png b/app/static/favicon-180x180.png
new file mode 100644
index 0000000..840e451
Binary files /dev/null and b/app/static/favicon-180x180.png differ
diff --git a/app/static/favicon-192x192.png b/app/static/favicon-192x192.png
new file mode 100644
index 0000000..ba1cfc6
Binary files /dev/null and b/app/static/favicon-192x192.png differ
diff --git a/app/static/favicon-32x32.png b/app/static/favicon-32x32.png
new file mode 100644
index 0000000..e60a375
Binary files /dev/null and b/app/static/favicon-32x32.png differ
diff --git a/app/static/favicon-48x48.png b/app/static/favicon-48x48.png
new file mode 100644
index 0000000..41fe36e
Binary files /dev/null and b/app/static/favicon-48x48.png differ
diff --git a/app/static/favicon-512x512.png b/app/static/favicon-512x512.png
new file mode 100644
index 0000000..0da728c
Binary files /dev/null and b/app/static/favicon-512x512.png differ
diff --git a/app/static/favicon.ico b/app/static/favicon.ico
new file mode 100644
index 0000000..a2052f5
Binary files /dev/null and b/app/static/favicon.ico differ
diff --git a/app/static/favicon.svg b/app/static/favicon.svg
new file mode 100644
index 0000000..cad45b0
--- /dev/null
+++ b/app/static/favicon.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/app/static/js/dynamic_gallery_paging.js b/app/static/js/dynamic_gallery_paging.js
index 2c7bc07..50093e0 100644
--- a/app/static/js/dynamic_gallery_paging.js
+++ b/app/static/js/dynamic_gallery_paging.js
@@ -1,26 +1,26 @@
-document.addEventListener("DOMContentLoaded", function () {
- const url = new URL(window.location.href);
- const page = url.searchParams.get("page") || "1";
- const perPage = url.searchParams.get("per_page");
-
- if (page !== "1" || perPage) return;
-
- const grid = document.querySelector(".gallery-grid");
- if (!grid) return;
-
- const sample = grid.querySelector(".gallery-item");
- if (!sample) return;
-
- const gridStyle = window.getComputedStyle(grid);
- const gridGap = parseFloat(gridStyle.getPropertyValue("gap")) || 0;
- const sampleWidth = sample.getBoundingClientRect().width + gridGap;
- const containerWidth = grid.getBoundingClientRect().width;
-
- const columns = Math.floor(containerWidth / sampleWidth);
- const calculatedPerPage = columns * 5;
-
- url.searchParams.set("per_page", calculatedPerPage);
- url.searchParams.set("page", "1");
-
- window.location.replace(url.toString());
-});
+document.addEventListener("DOMContentLoaded", function () {
+ const url = new URL(window.location.href);
+ const page = url.searchParams.get("page") || "1";
+ const perPage = url.searchParams.get("per_page");
+
+ if (page !== "1" || perPage) return;
+
+ const grid = document.querySelector(".gallery-grid");
+ if (!grid) return;
+
+ const sample = grid.querySelector(".gallery-item");
+ if (!sample) return;
+
+ const gridStyle = window.getComputedStyle(grid);
+ const gridGap = parseFloat(gridStyle.getPropertyValue("gap")) || 0;
+ const sampleWidth = sample.getBoundingClientRect().width + gridGap;
+ const containerWidth = grid.getBoundingClientRect().width;
+
+ const columns = Math.floor(containerWidth / sampleWidth);
+ const calculatedPerPage = columns * 5;
+
+ url.searchParams.set("per_page", calculatedPerPage);
+ url.searchParams.set("page", "1");
+
+ window.location.replace(url.toString());
+});
diff --git a/app/static/js/view-modal.js b/app/static/js/view-modal.js
index d5d2151..f73d661 100644
--- a/app/static/js/view-modal.js
+++ b/app/static/js/view-modal.js
@@ -1,826 +1,826 @@
-// /app/static/js/view-modal.js
-
-document.addEventListener('DOMContentLoaded', () => {
- const modal = document.getElementById('imageModal');
- const modalPrev = document.getElementById('modalPrev');
- const modalNext = document.getElementById('modalNext');
- const modalClose = document.getElementById('modalClose');
- 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;
- const tagAutocomplete = document.getElementById('tagAutocomplete');
-
- // Series info elements (when image IS in a series)
- const seriesInfoEl = document.getElementById('modalSeriesInfo');
- const seriesNameEl = document.getElementById('modalSeriesName');
- const pageNumberInput = document.getElementById('modalPageNumber');
- const updatePageBtn = document.getElementById('updatePageBtn');
- const pageUpdateStatus = document.getElementById('pageUpdateStatus');
- let currentSeriesPageId = null;
-
- // Add to series elements (when image is NOT in a series)
- const addToSeriesEl = document.getElementById('modalAddToSeries');
- const seriesSelect = document.getElementById('seriesSelect');
- const addSeriesPageNumber = document.getElementById('addSeriesPageNumber');
- const addToSeriesBtn = document.getElementById('addToSeriesBtn');
- const addToSeriesStatus = document.getElementById('addToSeriesStatus');
- let seriesTagsLoaded = false;
-
- // Autocomplete state
- let autocompleteItems = [];
- let autocompleteSelectedIndex = -1;
- let autocompleteDebounce = null;
-
- let images = Array.from(document.querySelectorAll('.img-clickable'));
- let currentIndex = -1;
- let isZoomed = false;
- let isDragging = false;
- let startX, startY, scrollLeft, scrollTop;
- let dragStartX = 0;
- let dragStartY = 0;
- let dragMoved = false;
-
- // Legacy pagination inputs (for non-infinite-scroll pages)
- const hasNextPageInput = document.getElementById('hasNextPage');
- const nextPageUrlInput = document.getElementById('nextPageUrl');
- const hasPrevPageInput = document.getElementById('hasPrevPage');
- 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
- // ---------------------------
- function setEditorImageId(id) {
- if (tagEditor) tagEditor.dataset.imageId = id || '';
- }
- function getEditorImageId() {
- return tagEditor ? tagEditor.dataset.imageId : '';
- }
- function getTagIcon(kind) {
- const icons = {
- artist: '🎨',
- archive: '🗜️',
- character: '👤',
- series: '📺',
- fandom: '🎭',
- rating: '⚠️',
- source: '🌐',
- post: '📌'
- };
- return icons[kind] || '#';
- }
-
- function getTagDisplayName(name) {
- // Remove prefix if present (e.g., "artist:Name" -> "Name")
- return name.includes(':') ? name.split(':', 2)[1] : name;
- }
-
- function renderTags(tags) {
- if (!tagList) return;
- tagList.innerHTML = '';
- (tags || []).forEach(t => {
- const chip = document.createElement('span');
- chip.className = 'tag-chip';
- const icon = getTagIcon(t.kind);
- const displayName = getTagDisplayName(t.name);
- chip.innerHTML = `${icon} ${displayName} `;
- 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
- }
- }
-
- // ---------------------------
- // Gallery item tag refresh (fallback if bulk-select.js not loaded)
- // ---------------------------
- function getTagDisplayNameForOverlay(name, kind) {
- if (['artist', 'character', 'series', 'fandom', 'rating'].includes(kind)) {
- return name.includes(':') ? name.split(':', 2)[1] : name;
- }
- return '#' + name;
- }
-
- async function refreshItemTagsFallback(imageId) {
- const item = document.querySelector(`.img-clickable[data-id="${imageId}"]`);
- if (!item) return;
-
- try {
- const r = await fetch(`/image/${imageId}/tags`);
- const data = await r.json();
- if (!data.ok) return;
-
- const visibleTags = (data.tags || []).filter(t => t.kind !== 'archive');
- let overlay = item.querySelector('.tag-overlay');
-
- if (visibleTags.length === 0) {
- if (overlay) overlay.remove();
- return;
- }
-
- if (!overlay) {
- overlay = document.createElement('div');
- overlay.className = 'tag-overlay';
- item.appendChild(overlay);
- }
-
- overlay.innerHTML = visibleTags.map(t => {
- const displayName = getTagDisplayNameForOverlay(t.name, t.kind);
- const icon = getTagIcon(t.kind);
- const encodedName = encodeURIComponent(t.name);
- const escapedName = t.name.replace(/"/g, '"');
- const escapedDisplay = displayName.replace(//g, '>');
- const prefix = icon !== '#' ? icon + ' ' : '#';
- return `${prefix}${escapedDisplay}`;
- }).join('');
- } catch (e) {
- console.error('Failed to refresh tags for image', imageId, e);
- }
- }
-
- // Register fallback if bulk-select.js hasn't registered it
- if (!window.refreshItemTags) {
- window.refreshItemTags = refreshItemTagsFallback;
- }
-
- // ---------------------------
- // Series info helpers
- // ---------------------------
- function hideSeriesInfo() {
- if (seriesInfoEl) seriesInfoEl.style.display = 'none';
- currentSeriesPageId = null;
- if (pageUpdateStatus) pageUpdateStatus.textContent = '';
- }
-
- function hideAddToSeries() {
- if (addToSeriesEl) addToSeriesEl.style.display = 'none';
- if (addToSeriesStatus) addToSeriesStatus.textContent = '';
- }
-
- async function loadSeriesTags() {
- if (!seriesSelect) return;
- if (seriesTagsLoaded) return Promise.resolve();
- try {
- // Fetch all series tags using the kind filter
- const r = await fetch('/api/tags/search?kind=series&limit=100');
- const j = await r.json();
- const seriesTags = j.tags || [];
-
- // Clear existing options except the first placeholder
- seriesSelect.innerHTML = '';
-
- seriesTags.forEach(tag => {
- const opt = document.createElement('option');
- opt.value = tag.id;
- // Display name without prefix
- opt.textContent = tag.name.includes(':') ? tag.name.split(':', 2)[1] : tag.name;
- seriesSelect.appendChild(opt);
- });
-
- seriesTagsLoaded = true;
- } catch {
- // ignore
- }
- }
-
- function showAddToSeries(seriesPage = null) {
- if (!addToSeriesEl) return;
- loadSeriesTags().then(() => {
- // Pre-fill if image is already in a series
- if (seriesPage && seriesSelect) {
- seriesSelect.value = seriesPage.series_tag_id;
- if (addSeriesPageNumber) addSeriesPageNumber.value = seriesPage.page_number;
- if (addToSeriesBtn) {
- addToSeriesBtn.disabled = false;
- addToSeriesBtn.textContent = 'Update';
- }
- } else {
- if (seriesSelect) seriesSelect.value = '';
- if (addSeriesPageNumber) addSeriesPageNumber.value = '1';
- if (addToSeriesBtn) {
- addToSeriesBtn.disabled = true;
- addToSeriesBtn.textContent = 'Add';
- }
- }
- });
- if (addToSeriesStatus) addToSeriesStatus.textContent = '';
- addToSeriesEl.style.display = 'block';
- }
-
- async function handleAddToSeries() {
- const imageId = getEditorImageId();
- const seriesTagId = seriesSelect?.value;
- const pageNumber = parseInt(addSeriesPageNumber?.value);
-
- if (!imageId || !seriesTagId || isNaN(pageNumber) || pageNumber < 1) return;
-
- const isUpdate = addToSeriesBtn?.textContent === 'Update';
- if (addToSeriesBtn) addToSeriesBtn.disabled = true;
- if (addToSeriesStatus) addToSeriesStatus.textContent = isUpdate ? 'Updating...' : 'Adding...';
-
- try {
- const r = await fetch(`/api/image/${imageId}/add-to-series`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ series_tag_id: parseInt(seriesTagId), page_number: pageNumber })
- });
- const j = await r.json();
-
- if (j.ok) {
- if (addToSeriesStatus) {
- addToSeriesStatus.textContent = '✓ Saved';
- setTimeout(() => { addToSeriesStatus.textContent = ''; }, 2000);
- }
- // Fully reload series info to show updated management interface
- await loadSeriesInfo(imageId);
- } else {
- if (addToSeriesStatus) addToSeriesStatus.textContent = j.error || 'Error';
- if (addToSeriesBtn) addToSeriesBtn.disabled = false;
- }
- } catch (e) {
- if (addToSeriesStatus) addToSeriesStatus.textContent = 'Error';
- if (addToSeriesBtn) addToSeriesBtn.disabled = false;
- }
- }
-
- function showSeriesInfo(seriesPage) {
- if (!seriesInfoEl || !seriesPage) return;
- currentSeriesPageId = seriesPage.id;
-
- // Display series name (strip prefix)
- const displayName = seriesPage.series_name.includes(':')
- ? seriesPage.series_name.split(':', 1)[1]
- : seriesPage.series_name;
- if (seriesNameEl) {
- seriesNameEl.textContent = displayName;
- seriesNameEl.href = `/gallery?tag=${encodeURIComponent(seriesPage.series_name)}`;
- }
-
- // Display page number
- if (pageNumberInput) {
- pageNumberInput.value = seriesPage.page_number;
- }
-
- if (pageUpdateStatus) pageUpdateStatus.textContent = '';
- seriesInfoEl.style.display = 'block';
- }
-
- async function loadSeriesInfo(imageId) {
- hideSeriesInfo();
- hideAddToSeries();
- if (!imageId) return;
- try {
- const r = await fetch(`/api/image/${imageId}/series-info`);
- const j = await r.json();
- if (j.ok && j.in_series) {
- // Show series info (with link) and pre-fill the dropdown
- showSeriesInfo(j.series_page);
- showAddToSeries(j.series_page);
- } else {
- // Show add-to-series option when not in a series
- // Auto-select if image has exactly one series tag
- const seriesTags = j.series_tags || [];
- if (seriesTags.length === 1) {
- showAddToSeries({ series_tag_id: seriesTags[0].id, page_number: 1 });
- } else {
- showAddToSeries();
- }
- }
- } catch {
- // ignore
- }
- }
-
- async function updatePageNumber() {
- if (!currentSeriesPageId || !pageNumberInput) return;
- const newPageNumber = parseInt(pageNumberInput.value);
- if (isNaN(newPageNumber) || newPageNumber < 1) {
- if (pageUpdateStatus) pageUpdateStatus.textContent = 'Invalid';
- return;
- }
-
- if (updatePageBtn) updatePageBtn.disabled = true;
- if (pageUpdateStatus) pageUpdateStatus.textContent = 'Saving...';
-
- try {
- const fd = new FormData();
- fd.append('page_number', newPageNumber);
- const r = await fetch(`/api/series/page/${currentSeriesPageId}/update`, {
- method: 'POST',
- body: fd
- });
- const j = await r.json();
- if (j.ok) {
- if (pageUpdateStatus) {
- pageUpdateStatus.textContent = '✓';
- setTimeout(() => { pageUpdateStatus.textContent = ''; }, 2000);
- }
- } else {
- if (pageUpdateStatus) pageUpdateStatus.textContent = j.error || 'Error';
- }
- } catch (e) {
- if (pageUpdateStatus) pageUpdateStatus.textContent = 'Error';
- } finally {
- if (updatePageBtn) updatePageBtn.disabled = false;
- }
- }
-
- // Wire up the save button
- if (updatePageBtn) {
- updatePageBtn.addEventListener('click', updatePageNumber);
- }
-
- // Allow Enter key in page number input to save
- if (pageNumberInput) {
- pageNumberInput.addEventListener('keydown', (e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- updatePageNumber();
- }
- });
- }
-
- // Enable/disable add button based on series selection
- if (seriesSelect) {
- seriesSelect.addEventListener('change', () => {
- if (addToSeriesBtn) {
- addToSeriesBtn.disabled = !seriesSelect.value;
- // Update button text: "Update" if image is in a series, "Add" if not
- if (seriesSelect.value && currentSeriesPageId) {
- addToSeriesBtn.textContent = 'Update';
- } else if (seriesSelect.value) {
- addToSeriesBtn.textContent = 'Add';
- }
- }
- });
- }
-
- // Add to series button click
- if (addToSeriesBtn) {
- addToSeriesBtn.addEventListener('click', handleAddToSeries);
- }
-
- // Allow Enter key in add-to-series page number input
- if (addSeriesPageNumber) {
- addSeriesPageNumber.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' && seriesSelect?.value) {
- e.preventDefault();
- handleAddToSeries();
- }
- });
- }
-
- 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;
- }
-
- // ---------------------------
- // Autocomplete helpers
- // ---------------------------
- function hideAutocomplete() {
- if (tagAutocomplete) {
- tagAutocomplete.classList.remove('active');
- tagAutocomplete.innerHTML = '';
- }
- autocompleteItems = [];
- autocompleteSelectedIndex = -1;
- }
-
- function positionAutocomplete() {
- if (!tagAutocomplete || !tagInput) return;
- const rect = tagInput.getBoundingClientRect();
- tagAutocomplete.style.top = `${rect.bottom + 4}px`;
- tagAutocomplete.style.left = `${rect.left}px`;
- tagAutocomplete.style.width = `${rect.width}px`;
- }
-
- function renderAutocomplete(tags) {
- if (!tagAutocomplete) return;
- autocompleteItems = tags;
- autocompleteSelectedIndex = -1;
-
- if (tags.length === 0) {
- tagAutocomplete.innerHTML = '
No matching tags
';
- positionAutocomplete();
- tagAutocomplete.classList.add('active');
- return;
- }
-
- tagAutocomplete.innerHTML = tags.map((t, i) => {
- const icon = getTagIcon(t.kind);
- const displayName = getTagDisplayName(t.name);
- return `
- ${icon} ${displayName}
- ${t.kind || 'user'}
-
`;
- }).join('');
- positionAutocomplete();
- tagAutocomplete.classList.add('active');
- }
-
- function selectAutocompleteItem(index) {
- const items = tagAutocomplete?.querySelectorAll('.tag-autocomplete-item');
- if (!items) return;
- items.forEach((el, i) => {
- el.classList.toggle('selected', i === index);
- });
- autocompleteSelectedIndex = index;
- }
-
- async function fetchAutocomplete(query) {
- try {
- // Exclude system-managed tag kinds from autocomplete (archive, post, source)
- const excludeKinds = 'archive,post,source';
- const url = query
- ? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8&exclude_kind=${excludeKinds}`
- : `/api/tags/search?limit=8&exclude_kind=${excludeKinds}`;
- const r = await fetch(url);
- const j = await r.json();
- renderAutocomplete(j.tags || []);
- } catch {
- hideAutocomplete();
- }
- }
-
- // Shared function to submit a tag (avoids page refresh issues with form dispatch)
- async function submitTag() {
- const id = getEditorImageId();
- const name = (tagInput?.value || '').trim();
- if (!id || !name) return;
- hideAutocomplete();
- await addTag(id, name);
- if (tagInput) tagInput.value = '';
- }
-
- if (tagInput) {
- // Show autocomplete on focus
- tagInput.addEventListener('focus', () => {
- const val = tagInput.value.trim();
- fetchAutocomplete(val);
- });
-
- // Hide autocomplete on blur (with delay for click)
- tagInput.addEventListener('blur', () => {
- setTimeout(hideAutocomplete, 200);
- });
-
- // Search as user types
- tagInput.addEventListener('input', () => {
- clearTimeout(autocompleteDebounce);
- autocompleteDebounce = setTimeout(() => {
- fetchAutocomplete(tagInput.value.trim());
- }, 150);
- });
-
- // Keyboard navigation
- tagInput.addEventListener('keydown', (e) => {
- if (!tagAutocomplete?.classList.contains('active')) return;
-
- if (e.key === 'ArrowDown') {
- e.preventDefault();
- const nextIndex = Math.min(autocompleteSelectedIndex + 1, autocompleteItems.length - 1);
- selectAutocompleteItem(nextIndex);
- } else if (e.key === 'ArrowUp') {
- e.preventDefault();
- const prevIndex = Math.max(autocompleteSelectedIndex - 1, 0);
- selectAutocompleteItem(prevIndex);
- } else if (e.key === 'Enter' && autocompleteSelectedIndex >= 0) {
- e.preventDefault();
- const selected = autocompleteItems[autocompleteSelectedIndex];
- if (selected) {
- tagInput.value = selected.name;
- submitTag();
- }
- } else if (e.key === 'Escape') {
- hideAutocomplete();
- }
- });
- }
-
- // Click on autocomplete item - use mousedown to fire before blur
- if (tagAutocomplete) {
- tagAutocomplete.addEventListener('mousedown', (e) => {
- e.preventDefault(); // Prevent blur from firing
- e.stopPropagation();
- const item = e.target.closest('.tag-autocomplete-item');
- if (!item) return;
- const name = item.dataset.name;
- if (tagInput && name) {
- tagInput.value = name;
- submitTag();
- }
- });
- }
-
- if (tagForm) {
- tagForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- await submitTag();
- });
- }
- 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) {
- const img = images[index];
- const fileType = img.dataset.type || "image";
- const imageId = img.dataset.id || "";
-
- // clear wrapper
- modalImageWrapper.innerHTML = "";
-
- if (fileType === "video") {
- const video = document.createElement("video");
- video.src = img.dataset.full;
- video.controls = true;
- video.autoplay = true;
- video.style.maxWidth = "100%";
- video.style.maxHeight = "100%";
- modalImageWrapper.appendChild(video);
- } else {
- const image = document.createElement("img");
- image.src = img.dataset.full;
- image.alt = "Full View";
- image.setAttribute("draggable", "false");
- image.id = "modalImage";
- modalImageWrapper.appendChild(image);
- }
-
- currentIndex = index;
- resetZoom();
-
- // NEW: load tags for this image into the modal editor
- setEditorImageId(imageId);
- loadTags(imageId);
- loadSeriesInfo(imageId);
- }
-
- function openModal(index) {
- modal.classList.add('active');
- updateImage(index);
- history.replaceState({ modalIndex: index }, '', window.location.href);
- // Focus the tag input after modal opens (desktop only - avoids keyboard popup on mobile)
- if (tagInput && !isTouchDevice()) {
- setTimeout(() => tagInput.focus(), 100);
- }
- }
-
- // Detect touch devices to avoid auto-focus keyboard popup
- function isTouchDevice() {
- return ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
- }
-
- function closeModal() {
- // Get the image ID before clearing so we can refresh its tags
- const imageId = getEditorImageId();
-
- modal.classList.remove('active');
- modalImageWrapper.innerHTML = '';
- currentIndex = -1;
- resetZoom();
- // clear tag UI
- setEditorImageId('');
- renderTags([]);
- hideSeriesInfo();
- hideAddToSeries();
- history.replaceState({}, '', window.location.pathname + window.location.search);
-
- // Refresh the gallery item's tags display (if function available from bulk-select.js)
- if (imageId && window.refreshItemTags) {
- window.refreshItemTags(imageId);
- }
- }
-
- function showNext() {
- if (currentIndex < images.length - 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") {
- // Legacy pagination mode - fetch next page
- fetch(nextPageUrlInput.value)
- .then(res => res.text())
- .then(html => {
- const parser = new DOMParser();
- const doc = parser.parseFromString(html, 'text/html');
- const newGallery = doc.querySelector('.gallery-grid');
- const newImages = doc.querySelectorAll('.img-clickable');
-
- if (newGallery && newImages.length > 0) {
- const gallery = document.querySelector('.gallery-grid');
- gallery.innerHTML = newGallery.innerHTML;
-
- images = Array.from(document.querySelectorAll('.img-clickable'));
- images.forEach((img, i) => {
- img.addEventListener('click', () => openModal(i));
- });
-
- hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false";
- nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || "";
- hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false";
- prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || "";
-
- history.pushState({}, '', nextPageUrlInput.value);
- openModal(0);
- }
- });
- }
- }
-
- function showPrev() {
- if (currentIndex > 0) {
- updateImage(currentIndex - 1);
- } else if (isInfiniteScrollMode) {
- // In infinite scroll mode, just stay at the first image
- return;
- } else if (hasPrevPageInput?.value === "true") {
- // Legacy pagination mode - fetch previous page
- fetch(prevPageUrlInput.value)
- .then(res => res.text())
- .then(html => {
- const parser = new DOMParser();
- const doc = parser.parseFromString(html, 'text/html');
- const newGallery = doc.querySelector('.gallery-grid');
- const newImages = doc.querySelectorAll('.img-clickable');
-
- if (newGallery && newImages.length > 0) {
- const gallery = document.querySelector('.gallery-grid');
- gallery.innerHTML = newGallery.innerHTML;
-
- images = Array.from(document.querySelectorAll('.img-clickable'));
- images.forEach((img, i) => {
- img.addEventListener('click', () => openModal(i));
- });
-
- hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false";
- nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || "";
- hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false";
- prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || "";
-
- history.pushState({}, '', prevPageUrlInput.value);
- openModal(images.length - 1);
- }
- });
- }
- }
-
- function toggleZoom() {
- const isImage = modalImageWrapper.querySelector('img') !== null;
- if (!isImage) return;
- isZoomed = !isZoomed;
- modalImageWrapper.classList.toggle('zoomed', isZoomed);
- modalImageWrapper.style.cursor = isZoomed ? 'grab' : '';
- if (!isZoomed) {
- modalImageWrapper.scrollLeft = 0;
- modalImageWrapper.scrollTop = 0;
- }
- }
-
- function resetZoom() {
- isZoomed = false;
- modalImageWrapper.classList.remove('zoomed');
- modalImageWrapper.scrollLeft = 0;
- modalImageWrapper.scrollTop = 0;
- modalImageWrapper.style.cursor = '';
- }
-
- modalImageWrapper.addEventListener('mousedown', (e) => {
- const isImage = modalImageWrapper.querySelector('img') !== null;
- if (!isZoomed || !isImage) return;
- isDragging = true;
- dragMoved = false;
- modalImageWrapper.style.cursor = 'grabbing';
- startX = e.pageX - modalImageWrapper.offsetLeft;
- startY = e.pageY - modalImageWrapper.offsetTop;
- scrollLeft = modalImageWrapper.scrollLeft;
- scrollTop = modalImageWrapper.scrollTop;
- dragStartX = e.pageX;
- dragStartY = e.pageY;
- });
-
- modalImageWrapper.addEventListener('mouseleave', () => {
- isDragging = false;
- const isImage = modalImageWrapper.querySelector('img') !== null;
- if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab';
- });
-
- modalImageWrapper.addEventListener('mouseup', () => {
- isDragging = false;
- const isImage = modalImageWrapper.querySelector('img') !== null;
- if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab';
- });
-
- modalImageWrapper.addEventListener('mousemove', (e) => {
- const isImage = modalImageWrapper.querySelector('img') !== null;
- if (!isDragging || !isZoomed || !isImage) return;
- e.preventDefault();
- dragMoved = true;
- const x = e.pageX - modalImageWrapper.offsetLeft;
- const y = e.pageY - modalImageWrapper.offsetTop;
- const walkX = x - startX;
- const walkY = y - startY;
- modalImageWrapper.scrollLeft = scrollLeft - walkX;
- modalImageWrapper.scrollTop = scrollTop - walkY;
- });
-
- modalImageWrapper.addEventListener('click', (e) => {
- if (dragMoved) {
- dragMoved = false;
- return;
- }
- toggleZoom();
- });
-
- modalClose?.addEventListener('click', closeModal);
- modalPrev?.addEventListener('click', showPrev);
- modalNext?.addEventListener('click', showNext);
-
- modal.addEventListener('click', (e) => {
- if (e.target === modal) closeModal();
- });
-
- document.addEventListener('keydown', (e) => {
- if (!modal.classList.contains('active')) return;
- if (e.key === 'ArrowRight') showNext();
- if (e.key === 'ArrowLeft') showPrev();
- if (e.key === 'Escape') closeModal();
- });
-
- // Use event delegation on the document for .img-clickable clicks
- // This allows dynamically added elements (e.g., from shuffle) to work
- document.addEventListener('click', (e) => {
- const clickable = e.target.closest('.img-clickable');
- if (!clickable) return;
-
- // Re-query images to get current state (may have changed from shuffle)
- images = Array.from(document.querySelectorAll('.img-clickable'));
- const index = images.indexOf(clickable);
- if (index !== -1) {
- openModal(index);
- }
- });
-
- // Listen for showcase updates to refresh the images array
- document.addEventListener('showcaseUpdated', () => {
- 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', () => {
- if (modal.classList.contains('active')) {
- closeModal();
- } else {
- location.reload();
- }
- });
-});
+// /app/static/js/view-modal.js
+
+document.addEventListener('DOMContentLoaded', () => {
+ const modal = document.getElementById('imageModal');
+ const modalPrev = document.getElementById('modalPrev');
+ const modalNext = document.getElementById('modalNext');
+ const modalClose = document.getElementById('modalClose');
+ 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;
+ const tagAutocomplete = document.getElementById('tagAutocomplete');
+
+ // Series info elements (when image IS in a series)
+ const seriesInfoEl = document.getElementById('modalSeriesInfo');
+ const seriesNameEl = document.getElementById('modalSeriesName');
+ const pageNumberInput = document.getElementById('modalPageNumber');
+ const updatePageBtn = document.getElementById('updatePageBtn');
+ const pageUpdateStatus = document.getElementById('pageUpdateStatus');
+ let currentSeriesPageId = null;
+
+ // Add to series elements (when image is NOT in a series)
+ const addToSeriesEl = document.getElementById('modalAddToSeries');
+ const seriesSelect = document.getElementById('seriesSelect');
+ const addSeriesPageNumber = document.getElementById('addSeriesPageNumber');
+ const addToSeriesBtn = document.getElementById('addToSeriesBtn');
+ const addToSeriesStatus = document.getElementById('addToSeriesStatus');
+ let seriesTagsLoaded = false;
+
+ // Autocomplete state
+ let autocompleteItems = [];
+ let autocompleteSelectedIndex = -1;
+ let autocompleteDebounce = null;
+
+ let images = Array.from(document.querySelectorAll('.img-clickable'));
+ let currentIndex = -1;
+ let isZoomed = false;
+ let isDragging = false;
+ let startX, startY, scrollLeft, scrollTop;
+ let dragStartX = 0;
+ let dragStartY = 0;
+ let dragMoved = false;
+
+ // Legacy pagination inputs (for non-infinite-scroll pages)
+ const hasNextPageInput = document.getElementById('hasNextPage');
+ const nextPageUrlInput = document.getElementById('nextPageUrl');
+ const hasPrevPageInput = document.getElementById('hasPrevPage');
+ 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
+ // ---------------------------
+ function setEditorImageId(id) {
+ if (tagEditor) tagEditor.dataset.imageId = id || '';
+ }
+ function getEditorImageId() {
+ return tagEditor ? tagEditor.dataset.imageId : '';
+ }
+ function getTagIcon(kind) {
+ const icons = {
+ artist: '🎨',
+ archive: '🗜️',
+ character: '👤',
+ series: '📺',
+ fandom: '🎭',
+ rating: '⚠️',
+ source: '🌐',
+ post: '📌'
+ };
+ return icons[kind] || '#';
+ }
+
+ function getTagDisplayName(name) {
+ // Remove prefix if present (e.g., "artist:Name" -> "Name")
+ return name.includes(':') ? name.split(':', 2)[1] : name;
+ }
+
+ function renderTags(tags) {
+ if (!tagList) return;
+ tagList.innerHTML = '';
+ (tags || []).forEach(t => {
+ const chip = document.createElement('span');
+ chip.className = 'tag-chip';
+ const icon = getTagIcon(t.kind);
+ const displayName = getTagDisplayName(t.name);
+ chip.innerHTML = `${icon} ${displayName} `;
+ 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
+ }
+ }
+
+ // ---------------------------
+ // Gallery item tag refresh (fallback if bulk-select.js not loaded)
+ // ---------------------------
+ function getTagDisplayNameForOverlay(name, kind) {
+ if (['artist', 'character', 'series', 'fandom', 'rating'].includes(kind)) {
+ return name.includes(':') ? name.split(':', 2)[1] : name;
+ }
+ return '#' + name;
+ }
+
+ async function refreshItemTagsFallback(imageId) {
+ const item = document.querySelector(`.img-clickable[data-id="${imageId}"]`);
+ if (!item) return;
+
+ try {
+ const r = await fetch(`/image/${imageId}/tags`);
+ const data = await r.json();
+ if (!data.ok) return;
+
+ const visibleTags = (data.tags || []).filter(t => t.kind !== 'archive');
+ let overlay = item.querySelector('.tag-overlay');
+
+ if (visibleTags.length === 0) {
+ if (overlay) overlay.remove();
+ return;
+ }
+
+ if (!overlay) {
+ overlay = document.createElement('div');
+ overlay.className = 'tag-overlay';
+ item.appendChild(overlay);
+ }
+
+ overlay.innerHTML = visibleTags.map(t => {
+ const displayName = getTagDisplayNameForOverlay(t.name, t.kind);
+ const icon = getTagIcon(t.kind);
+ const encodedName = encodeURIComponent(t.name);
+ const escapedName = t.name.replace(/"/g, '"');
+ const escapedDisplay = displayName.replace(//g, '>');
+ const prefix = icon !== '#' ? icon + ' ' : '#';
+ return `${prefix}${escapedDisplay}`;
+ }).join('');
+ } catch (e) {
+ console.error('Failed to refresh tags for image', imageId, e);
+ }
+ }
+
+ // Register fallback if bulk-select.js hasn't registered it
+ if (!window.refreshItemTags) {
+ window.refreshItemTags = refreshItemTagsFallback;
+ }
+
+ // ---------------------------
+ // Series info helpers
+ // ---------------------------
+ function hideSeriesInfo() {
+ if (seriesInfoEl) seriesInfoEl.style.display = 'none';
+ currentSeriesPageId = null;
+ if (pageUpdateStatus) pageUpdateStatus.textContent = '';
+ }
+
+ function hideAddToSeries() {
+ if (addToSeriesEl) addToSeriesEl.style.display = 'none';
+ if (addToSeriesStatus) addToSeriesStatus.textContent = '';
+ }
+
+ async function loadSeriesTags() {
+ if (!seriesSelect) return;
+ if (seriesTagsLoaded) return Promise.resolve();
+ try {
+ // Fetch all series tags using the kind filter
+ const r = await fetch('/api/tags/search?kind=series&limit=100');
+ const j = await r.json();
+ const seriesTags = j.tags || [];
+
+ // Clear existing options except the first placeholder
+ seriesSelect.innerHTML = '';
+
+ seriesTags.forEach(tag => {
+ const opt = document.createElement('option');
+ opt.value = tag.id;
+ // Display name without prefix
+ opt.textContent = tag.name.includes(':') ? tag.name.split(':', 2)[1] : tag.name;
+ seriesSelect.appendChild(opt);
+ });
+
+ seriesTagsLoaded = true;
+ } catch {
+ // ignore
+ }
+ }
+
+ function showAddToSeries(seriesPage = null) {
+ if (!addToSeriesEl) return;
+ loadSeriesTags().then(() => {
+ // Pre-fill if image is already in a series
+ if (seriesPage && seriesSelect) {
+ seriesSelect.value = seriesPage.series_tag_id;
+ if (addSeriesPageNumber) addSeriesPageNumber.value = seriesPage.page_number;
+ if (addToSeriesBtn) {
+ addToSeriesBtn.disabled = false;
+ addToSeriesBtn.textContent = 'Update';
+ }
+ } else {
+ if (seriesSelect) seriesSelect.value = '';
+ if (addSeriesPageNumber) addSeriesPageNumber.value = '1';
+ if (addToSeriesBtn) {
+ addToSeriesBtn.disabled = true;
+ addToSeriesBtn.textContent = 'Add';
+ }
+ }
+ });
+ if (addToSeriesStatus) addToSeriesStatus.textContent = '';
+ addToSeriesEl.style.display = 'block';
+ }
+
+ async function handleAddToSeries() {
+ const imageId = getEditorImageId();
+ const seriesTagId = seriesSelect?.value;
+ const pageNumber = parseInt(addSeriesPageNumber?.value);
+
+ if (!imageId || !seriesTagId || isNaN(pageNumber) || pageNumber < 1) return;
+
+ const isUpdate = addToSeriesBtn?.textContent === 'Update';
+ if (addToSeriesBtn) addToSeriesBtn.disabled = true;
+ if (addToSeriesStatus) addToSeriesStatus.textContent = isUpdate ? 'Updating...' : 'Adding...';
+
+ try {
+ const r = await fetch(`/api/image/${imageId}/add-to-series`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ series_tag_id: parseInt(seriesTagId), page_number: pageNumber })
+ });
+ const j = await r.json();
+
+ if (j.ok) {
+ if (addToSeriesStatus) {
+ addToSeriesStatus.textContent = '✓ Saved';
+ setTimeout(() => { addToSeriesStatus.textContent = ''; }, 2000);
+ }
+ // Fully reload series info to show updated management interface
+ await loadSeriesInfo(imageId);
+ } else {
+ if (addToSeriesStatus) addToSeriesStatus.textContent = j.error || 'Error';
+ if (addToSeriesBtn) addToSeriesBtn.disabled = false;
+ }
+ } catch (e) {
+ if (addToSeriesStatus) addToSeriesStatus.textContent = 'Error';
+ if (addToSeriesBtn) addToSeriesBtn.disabled = false;
+ }
+ }
+
+ function showSeriesInfo(seriesPage) {
+ if (!seriesInfoEl || !seriesPage) return;
+ currentSeriesPageId = seriesPage.id;
+
+ // Display series name (strip prefix)
+ const displayName = seriesPage.series_name.includes(':')
+ ? seriesPage.series_name.split(':', 1)[1]
+ : seriesPage.series_name;
+ if (seriesNameEl) {
+ seriesNameEl.textContent = displayName;
+ seriesNameEl.href = `/gallery?tag=${encodeURIComponent(seriesPage.series_name)}`;
+ }
+
+ // Display page number
+ if (pageNumberInput) {
+ pageNumberInput.value = seriesPage.page_number;
+ }
+
+ if (pageUpdateStatus) pageUpdateStatus.textContent = '';
+ seriesInfoEl.style.display = 'block';
+ }
+
+ async function loadSeriesInfo(imageId) {
+ hideSeriesInfo();
+ hideAddToSeries();
+ if (!imageId) return;
+ try {
+ const r = await fetch(`/api/image/${imageId}/series-info`);
+ const j = await r.json();
+ if (j.ok && j.in_series) {
+ // Show series info (with link) and pre-fill the dropdown
+ showSeriesInfo(j.series_page);
+ showAddToSeries(j.series_page);
+ } else {
+ // Show add-to-series option when not in a series
+ // Auto-select if image has exactly one series tag
+ const seriesTags = j.series_tags || [];
+ if (seriesTags.length === 1) {
+ showAddToSeries({ series_tag_id: seriesTags[0].id, page_number: 1 });
+ } else {
+ showAddToSeries();
+ }
+ }
+ } catch {
+ // ignore
+ }
+ }
+
+ async function updatePageNumber() {
+ if (!currentSeriesPageId || !pageNumberInput) return;
+ const newPageNumber = parseInt(pageNumberInput.value);
+ if (isNaN(newPageNumber) || newPageNumber < 1) {
+ if (pageUpdateStatus) pageUpdateStatus.textContent = 'Invalid';
+ return;
+ }
+
+ if (updatePageBtn) updatePageBtn.disabled = true;
+ if (pageUpdateStatus) pageUpdateStatus.textContent = 'Saving...';
+
+ try {
+ const fd = new FormData();
+ fd.append('page_number', newPageNumber);
+ const r = await fetch(`/api/series/page/${currentSeriesPageId}/update`, {
+ method: 'POST',
+ body: fd
+ });
+ const j = await r.json();
+ if (j.ok) {
+ if (pageUpdateStatus) {
+ pageUpdateStatus.textContent = '✓';
+ setTimeout(() => { pageUpdateStatus.textContent = ''; }, 2000);
+ }
+ } else {
+ if (pageUpdateStatus) pageUpdateStatus.textContent = j.error || 'Error';
+ }
+ } catch (e) {
+ if (pageUpdateStatus) pageUpdateStatus.textContent = 'Error';
+ } finally {
+ if (updatePageBtn) updatePageBtn.disabled = false;
+ }
+ }
+
+ // Wire up the save button
+ if (updatePageBtn) {
+ updatePageBtn.addEventListener('click', updatePageNumber);
+ }
+
+ // Allow Enter key in page number input to save
+ if (pageNumberInput) {
+ pageNumberInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ updatePageNumber();
+ }
+ });
+ }
+
+ // Enable/disable add button based on series selection
+ if (seriesSelect) {
+ seriesSelect.addEventListener('change', () => {
+ if (addToSeriesBtn) {
+ addToSeriesBtn.disabled = !seriesSelect.value;
+ // Update button text: "Update" if image is in a series, "Add" if not
+ if (seriesSelect.value && currentSeriesPageId) {
+ addToSeriesBtn.textContent = 'Update';
+ } else if (seriesSelect.value) {
+ addToSeriesBtn.textContent = 'Add';
+ }
+ }
+ });
+ }
+
+ // Add to series button click
+ if (addToSeriesBtn) {
+ addToSeriesBtn.addEventListener('click', handleAddToSeries);
+ }
+
+ // Allow Enter key in add-to-series page number input
+ if (addSeriesPageNumber) {
+ addSeriesPageNumber.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && seriesSelect?.value) {
+ e.preventDefault();
+ handleAddToSeries();
+ }
+ });
+ }
+
+ 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;
+ }
+
+ // ---------------------------
+ // Autocomplete helpers
+ // ---------------------------
+ function hideAutocomplete() {
+ if (tagAutocomplete) {
+ tagAutocomplete.classList.remove('active');
+ tagAutocomplete.innerHTML = '';
+ }
+ autocompleteItems = [];
+ autocompleteSelectedIndex = -1;
+ }
+
+ function positionAutocomplete() {
+ if (!tagAutocomplete || !tagInput) return;
+ const rect = tagInput.getBoundingClientRect();
+ tagAutocomplete.style.top = `${rect.bottom + 4}px`;
+ tagAutocomplete.style.left = `${rect.left}px`;
+ tagAutocomplete.style.width = `${rect.width}px`;
+ }
+
+ function renderAutocomplete(tags) {
+ if (!tagAutocomplete) return;
+ autocompleteItems = tags;
+ autocompleteSelectedIndex = -1;
+
+ if (tags.length === 0) {
+ tagAutocomplete.innerHTML = 'No matching tags
';
+ positionAutocomplete();
+ tagAutocomplete.classList.add('active');
+ return;
+ }
+
+ tagAutocomplete.innerHTML = tags.map((t, i) => {
+ const icon = getTagIcon(t.kind);
+ const displayName = getTagDisplayName(t.name);
+ return `
+ ${icon} ${displayName}
+ ${t.kind || 'user'}
+
`;
+ }).join('');
+ positionAutocomplete();
+ tagAutocomplete.classList.add('active');
+ }
+
+ function selectAutocompleteItem(index) {
+ const items = tagAutocomplete?.querySelectorAll('.tag-autocomplete-item');
+ if (!items) return;
+ items.forEach((el, i) => {
+ el.classList.toggle('selected', i === index);
+ });
+ autocompleteSelectedIndex = index;
+ }
+
+ async function fetchAutocomplete(query) {
+ try {
+ // Exclude system-managed tag kinds from autocomplete (archive, post, source)
+ const excludeKinds = 'archive,post,source';
+ const url = query
+ ? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8&exclude_kind=${excludeKinds}`
+ : `/api/tags/search?limit=8&exclude_kind=${excludeKinds}`;
+ const r = await fetch(url);
+ const j = await r.json();
+ renderAutocomplete(j.tags || []);
+ } catch {
+ hideAutocomplete();
+ }
+ }
+
+ // Shared function to submit a tag (avoids page refresh issues with form dispatch)
+ async function submitTag() {
+ const id = getEditorImageId();
+ const name = (tagInput?.value || '').trim();
+ if (!id || !name) return;
+ hideAutocomplete();
+ await addTag(id, name);
+ if (tagInput) tagInput.value = '';
+ }
+
+ if (tagInput) {
+ // Show autocomplete on focus
+ tagInput.addEventListener('focus', () => {
+ const val = tagInput.value.trim();
+ fetchAutocomplete(val);
+ });
+
+ // Hide autocomplete on blur (with delay for click)
+ tagInput.addEventListener('blur', () => {
+ setTimeout(hideAutocomplete, 200);
+ });
+
+ // Search as user types
+ tagInput.addEventListener('input', () => {
+ clearTimeout(autocompleteDebounce);
+ autocompleteDebounce = setTimeout(() => {
+ fetchAutocomplete(tagInput.value.trim());
+ }, 150);
+ });
+
+ // Keyboard navigation
+ tagInput.addEventListener('keydown', (e) => {
+ if (!tagAutocomplete?.classList.contains('active')) return;
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ const nextIndex = Math.min(autocompleteSelectedIndex + 1, autocompleteItems.length - 1);
+ selectAutocompleteItem(nextIndex);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ const prevIndex = Math.max(autocompleteSelectedIndex - 1, 0);
+ selectAutocompleteItem(prevIndex);
+ } else if (e.key === 'Enter' && autocompleteSelectedIndex >= 0) {
+ e.preventDefault();
+ const selected = autocompleteItems[autocompleteSelectedIndex];
+ if (selected) {
+ tagInput.value = selected.name;
+ submitTag();
+ }
+ } else if (e.key === 'Escape') {
+ hideAutocomplete();
+ }
+ });
+ }
+
+ // Click on autocomplete item - use mousedown to fire before blur
+ if (tagAutocomplete) {
+ tagAutocomplete.addEventListener('mousedown', (e) => {
+ e.preventDefault(); // Prevent blur from firing
+ e.stopPropagation();
+ const item = e.target.closest('.tag-autocomplete-item');
+ if (!item) return;
+ const name = item.dataset.name;
+ if (tagInput && name) {
+ tagInput.value = name;
+ submitTag();
+ }
+ });
+ }
+
+ if (tagForm) {
+ tagForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ await submitTag();
+ });
+ }
+ 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) {
+ const img = images[index];
+ const fileType = img.dataset.type || "image";
+ const imageId = img.dataset.id || "";
+
+ // clear wrapper
+ modalImageWrapper.innerHTML = "";
+
+ if (fileType === "video") {
+ const video = document.createElement("video");
+ video.src = img.dataset.full;
+ video.controls = true;
+ video.autoplay = true;
+ video.style.maxWidth = "100%";
+ video.style.maxHeight = "100%";
+ modalImageWrapper.appendChild(video);
+ } else {
+ const image = document.createElement("img");
+ image.src = img.dataset.full;
+ image.alt = "Full View";
+ image.setAttribute("draggable", "false");
+ image.id = "modalImage";
+ modalImageWrapper.appendChild(image);
+ }
+
+ currentIndex = index;
+ resetZoom();
+
+ // NEW: load tags for this image into the modal editor
+ setEditorImageId(imageId);
+ loadTags(imageId);
+ loadSeriesInfo(imageId);
+ }
+
+ function openModal(index) {
+ modal.classList.add('active');
+ updateImage(index);
+ history.replaceState({ modalIndex: index }, '', window.location.href);
+ // Focus the tag input after modal opens (desktop only - avoids keyboard popup on mobile)
+ if (tagInput && !isTouchDevice()) {
+ setTimeout(() => tagInput.focus(), 100);
+ }
+ }
+
+ // Detect touch devices to avoid auto-focus keyboard popup
+ function isTouchDevice() {
+ return ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
+ }
+
+ function closeModal() {
+ // Get the image ID before clearing so we can refresh its tags
+ const imageId = getEditorImageId();
+
+ modal.classList.remove('active');
+ modalImageWrapper.innerHTML = '';
+ currentIndex = -1;
+ resetZoom();
+ // clear tag UI
+ setEditorImageId('');
+ renderTags([]);
+ hideSeriesInfo();
+ hideAddToSeries();
+ history.replaceState({}, '', window.location.pathname + window.location.search);
+
+ // Refresh the gallery item's tags display (if function available from bulk-select.js)
+ if (imageId && window.refreshItemTags) {
+ window.refreshItemTags(imageId);
+ }
+ }
+
+ function showNext() {
+ if (currentIndex < images.length - 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") {
+ // Legacy pagination mode - fetch next page
+ fetch(nextPageUrlInput.value)
+ .then(res => res.text())
+ .then(html => {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, 'text/html');
+ const newGallery = doc.querySelector('.gallery-grid');
+ const newImages = doc.querySelectorAll('.img-clickable');
+
+ if (newGallery && newImages.length > 0) {
+ const gallery = document.querySelector('.gallery-grid');
+ gallery.innerHTML = newGallery.innerHTML;
+
+ images = Array.from(document.querySelectorAll('.img-clickable'));
+ images.forEach((img, i) => {
+ img.addEventListener('click', () => openModal(i));
+ });
+
+ hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false";
+ nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || "";
+ hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false";
+ prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || "";
+
+ history.pushState({}, '', nextPageUrlInput.value);
+ openModal(0);
+ }
+ });
+ }
+ }
+
+ function showPrev() {
+ if (currentIndex > 0) {
+ updateImage(currentIndex - 1);
+ } else if (isInfiniteScrollMode) {
+ // In infinite scroll mode, just stay at the first image
+ return;
+ } else if (hasPrevPageInput?.value === "true") {
+ // Legacy pagination mode - fetch previous page
+ fetch(prevPageUrlInput.value)
+ .then(res => res.text())
+ .then(html => {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, 'text/html');
+ const newGallery = doc.querySelector('.gallery-grid');
+ const newImages = doc.querySelectorAll('.img-clickable');
+
+ if (newGallery && newImages.length > 0) {
+ const gallery = document.querySelector('.gallery-grid');
+ gallery.innerHTML = newGallery.innerHTML;
+
+ images = Array.from(document.querySelectorAll('.img-clickable'));
+ images.forEach((img, i) => {
+ img.addEventListener('click', () => openModal(i));
+ });
+
+ hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false";
+ nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || "";
+ hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false";
+ prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || "";
+
+ history.pushState({}, '', prevPageUrlInput.value);
+ openModal(images.length - 1);
+ }
+ });
+ }
+ }
+
+ function toggleZoom() {
+ const isImage = modalImageWrapper.querySelector('img') !== null;
+ if (!isImage) return;
+ isZoomed = !isZoomed;
+ modalImageWrapper.classList.toggle('zoomed', isZoomed);
+ modalImageWrapper.style.cursor = isZoomed ? 'grab' : '';
+ if (!isZoomed) {
+ modalImageWrapper.scrollLeft = 0;
+ modalImageWrapper.scrollTop = 0;
+ }
+ }
+
+ function resetZoom() {
+ isZoomed = false;
+ modalImageWrapper.classList.remove('zoomed');
+ modalImageWrapper.scrollLeft = 0;
+ modalImageWrapper.scrollTop = 0;
+ modalImageWrapper.style.cursor = '';
+ }
+
+ modalImageWrapper.addEventListener('mousedown', (e) => {
+ const isImage = modalImageWrapper.querySelector('img') !== null;
+ if (!isZoomed || !isImage) return;
+ isDragging = true;
+ dragMoved = false;
+ modalImageWrapper.style.cursor = 'grabbing';
+ startX = e.pageX - modalImageWrapper.offsetLeft;
+ startY = e.pageY - modalImageWrapper.offsetTop;
+ scrollLeft = modalImageWrapper.scrollLeft;
+ scrollTop = modalImageWrapper.scrollTop;
+ dragStartX = e.pageX;
+ dragStartY = e.pageY;
+ });
+
+ modalImageWrapper.addEventListener('mouseleave', () => {
+ isDragging = false;
+ const isImage = modalImageWrapper.querySelector('img') !== null;
+ if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab';
+ });
+
+ modalImageWrapper.addEventListener('mouseup', () => {
+ isDragging = false;
+ const isImage = modalImageWrapper.querySelector('img') !== null;
+ if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab';
+ });
+
+ modalImageWrapper.addEventListener('mousemove', (e) => {
+ const isImage = modalImageWrapper.querySelector('img') !== null;
+ if (!isDragging || !isZoomed || !isImage) return;
+ e.preventDefault();
+ dragMoved = true;
+ const x = e.pageX - modalImageWrapper.offsetLeft;
+ const y = e.pageY - modalImageWrapper.offsetTop;
+ const walkX = x - startX;
+ const walkY = y - startY;
+ modalImageWrapper.scrollLeft = scrollLeft - walkX;
+ modalImageWrapper.scrollTop = scrollTop - walkY;
+ });
+
+ modalImageWrapper.addEventListener('click', (e) => {
+ if (dragMoved) {
+ dragMoved = false;
+ return;
+ }
+ toggleZoom();
+ });
+
+ modalClose?.addEventListener('click', closeModal);
+ modalPrev?.addEventListener('click', showPrev);
+ modalNext?.addEventListener('click', showNext);
+
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) closeModal();
+ });
+
+ document.addEventListener('keydown', (e) => {
+ if (!modal.classList.contains('active')) return;
+ if (e.key === 'ArrowRight') showNext();
+ if (e.key === 'ArrowLeft') showPrev();
+ if (e.key === 'Escape') closeModal();
+ });
+
+ // Use event delegation on the document for .img-clickable clicks
+ // This allows dynamically added elements (e.g., from shuffle) to work
+ document.addEventListener('click', (e) => {
+ const clickable = e.target.closest('.img-clickable');
+ if (!clickable) return;
+
+ // Re-query images to get current state (may have changed from shuffle)
+ images = Array.from(document.querySelectorAll('.img-clickable'));
+ const index = images.indexOf(clickable);
+ if (index !== -1) {
+ openModal(index);
+ }
+ });
+
+ // Listen for showcase updates to refresh the images array
+ document.addEventListener('showcaseUpdated', () => {
+ 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', () => {
+ if (modal.classList.contains('active')) {
+ closeModal();
+ } else {
+ location.reload();
+ }
+ });
+});
diff --git a/app/static/style.css b/app/static/style.css
index 09a654a..b05dd52 100644
--- a/app/static/style.css
+++ b/app/static/style.css
@@ -143,6 +143,58 @@ header {
transform: translateY(0);
}
+/* Nav dropdown */
+.nav-dropdown {
+ position: relative;
+ display: inline-block;
+}
+
+.nav-dropdown-trigger {
+ cursor: pointer;
+ border: none;
+ background: transparent;
+}
+
+.nav-dropdown-trigger::after {
+ content: " \25BC";
+ font-size: 0.65em;
+ opacity: 0.7;
+}
+
+.nav-dropdown-menu {
+ display: none;
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ min-width: 150px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+ z-index: 1001;
+ padding: 0.5rem 0;
+ margin-top: 0.25rem;
+}
+
+.nav-dropdown-menu a {
+ display: block;
+ padding: 0.5rem 1rem;
+ color: var(--text);
+ text-decoration: none;
+ font-size: 0.95rem;
+ transition: background 0.15s ease;
+}
+
+.nav-dropdown-menu a:hover {
+ background: var(--hover);
+}
+
+.nav-dropdown:hover .nav-dropdown-menu,
+.nav-dropdown:focus-within .nav-dropdown-menu {
+ display: block;
+}
+
/*------------------------------------------------------------------------------
Flash messages
------------------------------------------------------------------------------*/
@@ -1003,6 +1055,42 @@ header {
filter: brightness(1.1);
}
+/* Tag search bar */
+.tag-search-bar {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ max-width: 500px;
+ margin: 0 auto 1rem;
+}
+
+.tag-search-input {
+ flex: 1;
+ padding: 0.6rem 1rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface);
+ color: var(--text);
+ font-size: 1rem;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+.tag-search-input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
+}
+
+.tag-search-input::placeholder {
+ color: var(--muted);
+}
+
+.tag-search-count {
+ color: var(--muted);
+ font-size: 0.9rem;
+ white-space: nowrap;
+}
+
/* Filter pills (top of tags_list) */
.filter-bar {
display: flex;
diff --git a/app/templates/_pagination.html b/app/templates/_pagination.html
index 7e5c26a..6655cd2 100644
--- a/app/templates/_pagination.html
+++ b/app/templates/_pagination.html
@@ -1,74 +1,74 @@
-
-{% if images.pages > 1 %}
-
-{% endif %}
+
+{% if images.pages > 1 %}
+
+{% endif %}
diff --git a/app/templates/gallery.html b/app/templates/gallery.html
index b3bd6d8..8e65a02 100644
--- a/app/templates/gallery.html
+++ b/app/templates/gallery.html
@@ -1,268 +1,268 @@
-
-{% extends "layout.html" %}
-{% block title %}Gallery{% endblock %}
-
-{% block content %}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% if post_metadata %}
-
-
- {% endif %}
-
- {% if series_info %}
-
-
- {% endif %}
-
-
-
- {% for year_month_key, year_month_label, images in grouped_images %}
-
-
- {{ year_month_label }}
- {{ images|length }} images
-
-
- {% for image in images %}
- {% include '_gallery_item.html' %}
- {% endfor %}
-
-
- {% else %}
-
-
No images found{% if active_tag %} for tag "{{ active_tag }}"{% endif %}.
-
- {% endfor %}
-
-
-
-
-
- Loading more images...
-
-
-
-
- No more images
-
-
-
-
-
-{% include '_gallery_modal.html' %}
-
-
-
-
-
-
-
-
-
-
-
-{% if series_info %}
-
-{% endif %}
-{% endblock %}
+
+{% extends "layout.html" %}
+{% block title %}Gallery{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if post_metadata %}
+
+
+ {% endif %}
+
+ {% if series_info %}
+
+
+ {% endif %}
+
+
+
+ {% for year_month_key, year_month_label, images in grouped_images %}
+
+
+ {{ year_month_label }}
+ {{ images|length }} images
+
+
+ {% for image in images %}
+ {% include '_gallery_item.html' %}
+ {% endfor %}
+
+
+ {% else %}
+
+
No images found{% if active_tag %} for tag "{{ active_tag }}"{% endif %}.
+
+ {% endfor %}
+
+
+
+
+
+ Loading more images...
+
+
+
+
+ No more images
+
+
+
+
+
+{% include '_gallery_modal.html' %}
+
+
+
+
+
+
+
+
+
+
+
+{% if series_info %}
+
+{% endif %}
+{% endblock %}
diff --git a/app/templates/layout.html b/app/templates/layout.html
index f9147a0..d5dcb2a 100644
--- a/app/templates/layout.html
+++ b/app/templates/layout.html
@@ -3,6 +3,9 @@
ImageRepo - {% block title %}{% endblock %}
+
+
+
@@ -13,8 +16,21 @@
diff --git a/app/templates/settings.html b/app/templates/settings.html
index a485ac6..bc25ab9 100644
--- a/app/templates/settings.html
+++ b/app/templates/settings.html
@@ -1,1819 +1,1819 @@
-{% extends "layout.html" %}
-{% block title %}Settings{% endblock %}
-
-{% block content %}
-Settings
-
-
-
-
-
-
-
System Overview
-
-
-
-
-
-
-
- -
- Total Images
-
-
- -
- Total Tags
-
-
- -
- Storage Used
-
-
- -
- Files to Import
-
-
- -
- Archives to Import
-
-
-
-
-
-
-
-
-
-
-
Import Queue Status
-
Monitor and control the Celery-based import queue system.
-
-
-
-
- -
- Pending
-
-
- -
- Queued
-
-
- -
- Processing
-
-
- -
- Complete
-
-
- -
- Skipped
-
-
- -
- Failed
-
-
-
-
-
- :
- - today,
- - this week,
- - older
-
-
-
-
Active Batch
-
Processing -
-
-
-
-
-
-
-
-
-
-
-
-
-
- Clear tasks:
-
-
-
-
-
- Note: Failed/skipped tasks older than 7 days are auto-cleaned daily.
-
-
-
-
-
-
-
-
-
-
-
- Workers: - |
- Active Tasks: -
-
-
-
-
-
-
-
-
Import Tasks
-
-
-
-
-
-
-
-
- Showing 0 of 0 tasks
-
-
-
-
-
-
- | File |
- Type |
- Status |
- Error/Reason |
- Time |
-
-
-
- | Loading... |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Status:
-
-
-
- Retries:
- Created:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Import Filters
-
Configure filters to skip certain images during import.
-
-
-
-
-
-
-
-
-
-
-
Delete Images by Tag
-
Permanently delete all images associated with a specific tag.
-
-
-
-
-
-
-
-
-
-
Clean Up Existing Images
-
Scan and remove images that don't meet filter criteria.
-
-
-
-
-
-
Scan Results
-
0 images match the filter criteria.
-
-
-
-
-
-
-
-
-
Selected: 0 images
-
-
-
-
-
-
-
-
-
-
-
-
Duplicate Detection
-
Scan for visually similar images using perceptual hash comparison. The first image in each group has the highest resolution and will be kept.
-
-
-
-
-
-
Scan Results
-
Found 0 group(s) of potential duplicates.
-
-
-
-
-
-
- Selected for deletion: 0 image(s)
- (The highest resolution image in each group is protected)
-
-
-
-
-
-
-
-
-
-
-{% endblock %}
+{% extends "layout.html" %}
+{% block title %}Settings{% endblock %}
+
+{% block content %}
+Settings
+
+
+
+
+
+
+
System Overview
+
+
+
+
+
+
+
+ -
+ Total Images
+
+
+ -
+ Total Tags
+
+
+ -
+ Storage Used
+
+
+ -
+ Files to Import
+
+
+ -
+ Archives to Import
+
+
+
+
+
+
+
+
+
+
+
Import Queue Status
+
Monitor and control the Celery-based import queue system.
+
+
+
+
+ -
+ Pending
+
+
+ -
+ Queued
+
+
+ -
+ Processing
+
+
+ -
+ Complete
+
+
+ -
+ Skipped
+
+
+ -
+ Failed
+
+
+
+
+
+ :
+ - today,
+ - this week,
+ - older
+
+
+
+
Active Batch
+
Processing -
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Clear tasks:
+
+
+
+
+
+ Note: Failed/skipped tasks older than 7 days are auto-cleaned daily.
+
+
+
+
+
+
+
+
+
+
+
+ Workers: - |
+ Active Tasks: -
+
+
+
+
+
+
+
+
Import Tasks
+
+
+
+
+
+
+
+
+ Showing 0 of 0 tasks
+
+
+
+
+
+
+ | File |
+ Type |
+ Status |
+ Error/Reason |
+ Time |
+
+
+
+ | Loading... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Status:
+
+
+
+ Retries:
+ Created:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Import Filters
+
Configure filters to skip certain images during import.
+
+
+
+
+
+
+
+
+
+
+
Delete Images by Tag
+
Permanently delete all images associated with a specific tag.
+
+
+
+
+
+
+
+
+
+
Clean Up Existing Images
+
Scan and remove images that don't meet filter criteria.
+
+
+
+
+
+
Scan Results
+
0 images match the filter criteria.
+
+
+
+
+
+
+
+
+
Selected: 0 images
+
+
+
+
+
+
+
+
+
+
+
+
Duplicate Detection
+
Scan for visually similar images using perceptual hash comparison. The first image in each group has the highest resolution and will be kept.
+
+
+
+
+
+
Scan Results
+
Found 0 group(s) of potential duplicates.
+
+
+
+
+
+
+ Selected for deletion: 0 image(s)
+ (The highest resolution image in each group is protected)
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/templates/tags_list.html b/app/templates/tags_list.html
index 257ce18..9dc2ac5 100644
--- a/app/templates/tags_list.html
+++ b/app/templates/tags_list.html
@@ -1,221 +1,287 @@
-{% extends "layout.html" %}
-{% block title %}
- {%- if active_kind == 'artist' -%}Artists
- {%- elif active_kind == 'character' -%}Characters
- {%- elif active_kind == 'series' -%}Series
- {%- elif active_kind == 'fandom' -%}Fandoms
- {%- elif active_kind == 'rating' -%}Ratings
- {%- elif active_kind == 'archive' -%}Archives
- {%- elif active_kind == 'source' -%}Sources
- {%- elif active_kind == 'post' -%}Posts
- {%- elif active_kind == 'user' -%}User Tags
- {%- else -%}Tags{%- endif -%}
-{% endblock %}
-
-{% block content %}
-
- {%- if active_kind == 'artist' -%}Artists
- {%- elif active_kind == 'character' -%}Characters
- {%- elif active_kind == 'series' -%}Series
- {%- elif active_kind == 'fandom' -%}Fandoms
- {%- elif active_kind == 'rating' -%}Ratings
- {%- elif active_kind == 'archive' -%}Archives
- {%- elif active_kind == 'source' -%}Sources
- {%- elif active_kind == 'post' -%}Posts
- {%- elif active_kind == 'user' -%}User Tags
- {%- else -%}All Tags{%- endif -%}
-
-
-
-
-
-{% if tag_data %}
-
- {% include '_tag_cards.html' %}
-
-
-
-
-
- Loading more tags...
-
-
-
-
-{% else %}
-No tags found in this category.
-{% endif %}
-
-
-
-
-
-
-{% endblock %}
+{% extends "layout.html" %}
+{% block title %}
+ {%- if active_kind == 'artist' -%}Artists
+ {%- elif active_kind == 'character' -%}Characters
+ {%- elif active_kind == 'series' -%}Series
+ {%- elif active_kind == 'fandom' -%}Fandoms
+ {%- elif active_kind == 'rating' -%}Ratings
+ {%- elif active_kind == 'archive' -%}Archives
+ {%- elif active_kind == 'source' -%}Sources
+ {%- elif active_kind == 'post' -%}Posts
+ {%- elif active_kind == 'user' -%}User Tags
+ {%- else -%}Tags{%- endif -%}
+{% endblock %}
+
+{% block content %}
+
+ {%- if active_kind == 'artist' -%}Artists
+ {%- elif active_kind == 'character' -%}Characters
+ {%- elif active_kind == 'series' -%}Series
+ {%- elif active_kind == 'fandom' -%}Fandoms
+ {%- elif active_kind == 'rating' -%}Ratings
+ {%- elif active_kind == 'archive' -%}Archives
+ {%- elif active_kind == 'source' -%}Sources
+ {%- elif active_kind == 'post' -%}Posts
+ {%- elif active_kind == 'user' -%}User Tags
+ {%- else -%}All Tags{%- endif -%}
+
+
+
+
+
+ {{ total_count }} tags
+
+
+
+
+
+{% if tag_data %}
+
+ {% include '_tag_cards.html' %}
+
+
+
+
+
+ Loading more tags...
+
+
+
+
+{% else %}
+No tags found in this category.
+{% endif %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/utils/image_importer.py b/app/utils/image_importer.py
index 31618c6..7e071b1 100644
--- a/app/utils/image_importer.py
+++ b/app/utils/image_importer.py
@@ -1,945 +1,945 @@
-# app/utils/image_importer.py
-
-from __future__ import annotations
-
-from datetime import datetime
-from pathlib import Path
-import hashlib
-import json
-import logging
-import mimetypes
-import os
-import re
-import shutil
-import subprocess
-import tempfile
-import uuid
-
-log = logging.getLogger(__name__)
-
-from PIL import Image, ImageOps
-import exifread
-import imagehash
-
-from app import db
-from app.models import ImageRecord, Tag, ArchiveRecord
-
-FFMPEG_THUMB_TIMEOUT = int(os.environ.get("THUMBS_FFMPEG_TIMEOUT", "60")) # seconds
-FFMPEG_TRANSCODE_TIMEOUT = int(os.environ.get("FFMPEG_TRANSCODE_TIMEOUT", "600")) # 10 min default
-Image.MAX_IMAGE_PIXELS = int(os.environ.get("PIL_MAX_IMAGE_PIXELS", "178956970"))
-ARCHIVE_NUM_WIDTH = int(os.environ.get("ARCHIVE_NUM_WIDTH", "4"))
-
-
-# =============================================================================
-# Configuration & Constants
-# =============================================================================
-
-THUMB_SIZE = (400, 400)
-
-# Media and archive extensions we handle
-IMAGE_EXTS = (".jpg", ".jpeg", ".jfif", ".png", ".gif", ".bmp", ".tiff", ".webp")
-VIDEO_EXTS = (".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv")
-VIDEO_EXTS_NEED_TRANSCODE = (".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv") # Non-MP4 formats
-ALLOWED_MEDIA_EXTS = IMAGE_EXTS + VIDEO_EXTS
-ALLOWED_ARCHIVE_EXTS = (
- ".zip", ".rar", ".cbr", ".7z",
- ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz"
-)
-
-# Import filter settings (loaded from file or env)
-IMPORT_SETTINGS_PATH = "/import/settings.json"
-
-
-def load_import_settings() -> dict:
- """
- Load import filter settings from database, with file and environment fallbacks.
- Priority: DB settings > file settings > environment variables
- """
- # Start with environment defaults
- defaults = {
- "min_width": int(os.environ.get("IMPORT_MIN_WIDTH", "0")),
- "min_height": int(os.environ.get("IMPORT_MIN_HEIGHT", "0")),
- "skip_transparent": os.environ.get("IMPORT_SKIP_TRANSPARENT", "false").lower() == "true",
- "transparency_threshold": float(os.environ.get("IMPORT_TRANSPARENCY_THRESHOLD", "0.9")),
- "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:
- if os.path.exists(IMPORT_SETTINGS_PATH):
- with open(IMPORT_SETTINGS_PATH, "r") as f:
- file_settings = json.load(f)
- defaults.update(file_settings)
- except Exception as 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
-
-
-def is_mostly_transparent(file_path: str, threshold: float = 0.9) -> bool:
- """
- Check if an image is mostly transparent (> threshold % of pixels are transparent).
- Returns False for images without alpha channel.
-
- Args:
- file_path: Path to the image file
- threshold: Percentage of pixels that must be transparent (0.0-1.0, default 0.9 = 90%)
-
- Returns:
- True if the image has > threshold% transparent pixels
- """
- try:
- with Image.open(file_path) as img:
- original_mode = img.mode
-
- # Handle different image modes that may have transparency
- if original_mode == "RGBA":
- # Already has alpha channel
- pass
- elif original_mode == "LA":
- # Grayscale with alpha
- img = img.convert("RGBA")
- elif original_mode == "P":
- # Palette mode - check for transparency
- if "transparency" in img.info:
- img = img.convert("RGBA")
- else:
- return False
- elif original_mode == "PA":
- # Palette with alpha
- img = img.convert("RGBA")
- else:
- # RGB, L, etc. - no alpha channel
- return False
-
- # Get alpha channel
- bands = img.split()
- if len(bands) < 4:
- return False
-
- alpha = bands[3] # Alpha is the 4th channel (index 3)
- alpha_data = alpha.getdata()
- total_pixels = alpha.size[0] * alpha.size[1]
-
- if total_pixels == 0:
- return False
-
- # Count pixels where alpha < 128 (more than 50% transparent)
- transparent_count = sum(1 for p in alpha_data if p < 128)
- transparency_ratio = transparent_count / total_pixels
-
- return transparency_ratio > threshold
- except Exception as e:
- print(f"[WARN] Failed to check transparency for {file_path}: {e}")
- 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
-RAR_PART_RE = re.compile(r"\.part(\d+)\.rar$", re.IGNORECASE)
-SEVENZ_PART_RE = re.compile(r"\.7z\.(\d{3})$", re.IGNORECASE)
-OLD_RAR_VOL_RE = re.compile(r"\.r\d{2}$", re.IGNORECASE) # .r00, .r01, ...
-
-# Environment configuration (optional)
-ENV_TMPDIR = "ARCHIVE_TMPDIR" # where to extract archives (default: system tmp)
-ENV_MINFREE = "ARCHIVE_MIN_FREE_GB" # min free GB required to start extraction (float; 0 = disabled)
-
-
-# =============================================================================
-# Utilities: Temp space management & free-space guard
-# =============================================================================
-
-def get_tmp_base() -> Path:
- """Return the base temp directory for archive extraction."""
- return Path(os.environ.get(ENV_TMPDIR, tempfile.gettempdir())).resolve()
-
-
-def cleanup_stale_tempdirs(prefix: str = "extract_") -> None:
- """Delete any leftover extraction directories from previous crashes."""
- base = get_tmp_base()
- base.mkdir(parents=True, exist_ok=True)
- for entry in base.glob(f"{prefix}*"):
- if entry.is_dir():
- try:
- shutil.rmtree(entry, ignore_errors=True)
- except Exception:
- pass
-
-
-def ensure_free_space_or_raise(path: Path, min_free_gb: float) -> None:
- """Raise if there is not enough free space at path."""
- if min_free_gb <= 0:
- return
- total, used, free = shutil.disk_usage(str(path))
- free_gb = free / (1024 ** 3)
- if free_gb < min_free_gb:
- raise RuntimeError(
- f"Insufficient free space at {path}: {free_gb:.1f} GB free "
- f"(required >= {min_free_gb:.1f} GB)."
- )
-
-
-# =============================================================================
-# Utilities: External extractors with resilient fallback
-# =============================================================================
-
-class ExtractError(Exception):
- """Raised when archive extraction fails with all strategies."""
-
-
-def _run_cmd(cmd: list[str]) -> tuple[int, str, str]:
- p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
- return p.returncode, p.stdout, p.stderr
-
-
-def _count_extracted_files(out_dir: str) -> int:
- """Count all files (recursively) in the output directory."""
- count = 0
- for root, dirs, files in os.walk(out_dir):
- count += len(files)
- return count
-
-
-def extract_with_unar(archive_path: str, out_dir: str) -> None:
- os.makedirs(out_dir, exist_ok=True)
- rc, out, err = _run_cmd(["unar", "-o", out_dir, archive_path])
- if rc != 0:
- # Check if files were actually extracted (partial success)
- # unar may return non-zero even when most files extracted successfully
- extracted_files = _count_extracted_files(out_dir)
- if extracted_files > 0:
- # Partial extraction - log warning but continue
- log.warning(f"unar partial extraction (rc={rc}): {extracted_files} files extracted from {archive_path}")
- return
- raise ExtractError(f"unar failed (rc={rc})\nSTDOUT:\n{out}\nSTDERR:\n{err}")
-
-
-def extract_with_7z(archive_path: str, out_dir: str) -> None:
- os.makedirs(out_dir, exist_ok=True)
- rc, out, err = _run_cmd(["7z", "x", "-y", f"-o{out_dir}", archive_path])
- if rc != 0:
- # Check if files were actually extracted (partial success)
- extracted_files = _count_extracted_files(out_dir)
- if extracted_files > 0:
- # Partial extraction - log warning but continue
- log.warning(f"7z partial extraction (rc={rc}): {extracted_files} files extracted from {archive_path}")
- return
- raise ExtractError(f"7z failed (rc={rc})\nSTDOUT:\n{out}\nSTDERR:\n{err}")
-
-
-def looks_password_protected_with_lsar(archive_path: str) -> bool:
- """Best-effort pre-check for 'Encrypted/Password' in lsar output."""
- try:
- rc, out, err = _run_cmd(["lsar", archive_path])
- txt = (out + "\n" + err).lower()
- return ("encrypted" in txt) or ("password" in txt)
- except Exception:
- return False
-
-
-def extract_archive_resilient(archive_path: str, out_dir: str) -> None:
- """
- Prefer unar for RAR/CBR (RAR5-friendly); fallback to 7z.
- For other formats, try 7z first then unar.
- Surfaces stderr so logs show CRC/password/permissions issues.
- """
- lower = archive_path.lower()
- errors = []
-
- # Optional password hint to skip early
- if lower.endswith((".rar", ".cbr")) and looks_password_protected_with_lsar(archive_path):
- raise ExtractError("Archive appears password-protected; skipping (no password provided).")
-
- if lower.endswith((".rar", ".cbr")):
- order = (extract_with_unar, extract_with_7z)
- else:
- order = (extract_with_7z, extract_with_unar)
-
- for fn in order:
- try:
- fn(archive_path, out_dir)
- return
- except ExtractError as e:
- errors.append(str(e))
-
- raise ExtractError("Both extractors failed:\n" + "\n---\n".join(errors))
-
-
-# =============================================================================
-# Content-addressed storage helpers
-# =============================================================================
-
-def build_hashed_dest_path(target_dir: str, original_name: str, content_hash: str) -> str:
- """
- Always produce a content-addressed destination:
- /__
- 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
-
-
-# =============================================================================
-# Helpers: Thumbnails, metadata, hashing, similarity
-# =============================================================================
-
-def generate_thumbnail(
- image_path,
- size=(400, 400),
- overwrite=False,
- prefer_jpeg=False, # False = auto-pick PNG for alpha images; True = always JPEG
- jpeg_bg=(0, 0, 0), # background used when flattening to JPEG
-):
- """
- Save thumbnails mirrored under /images/thumbs/<...>.
- 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`.
-
- 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.
- """
- images_root = Path("/images").resolve()
- image_path = Path(image_path).resolve()
-
- try:
- rel_path = image_path.relative_to(images_root)
- except ValueError:
- raise ValueError(f"{image_path} is not under {images_root}")
-
- # Base path under /images/thumbs with same subfolders/filename (we may change the suffix)
- thumb_base = (images_root / "thumbs" / rel_path).with_suffix("") # drop original suffix for control
-
- # Decide output extension/format
- with Image.open(image_path) as img:
- img = ImageOps.exif_transpose(img) # honor camera rotation
-
- # 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)
-
- if prefer_jpeg:
- # Force JPEG for consistency
- out_path = thumb_base.with_suffix(".jpg")
- out_path.parent.mkdir(parents=True, exist_ok=True)
- if not overwrite and out_path.exists():
- return str(out_path)
- img_rgb = _flatten_to_rgb(img, bg=jpeg_bg)
- img_rgb.save(out_path, format="JPEG", quality=85, optimize=True, progressive=True)
- return str(out_path)
- else:
- # Auto-pick: PNG for alpha, JPEG otherwise
- if has_alpha:
- out_path = thumb_base.with_suffix(".png")
- out_path.parent.mkdir(parents=True, exist_ok=True)
- if not overwrite and out_path.exists():
- return str(out_path)
- # Keep transparency if present
- # Convert palette to RGBA for reliable PNG writing
- if img.mode == "P":
- img = img.convert("RGBA")
- img.save(out_path, format="PNG", optimize=True)
- return str(out_path)
- else:
- out_path = thumb_base.with_suffix(".jpg")
- out_path.parent.mkdir(parents=True, exist_ok=True)
- if not overwrite and out_path.exists():
- return str(out_path)
- if img.mode != "RGB":
- img = img.convert("RGB")
- img.save(out_path, format="JPEG", quality=85, optimize=True, progressive=True)
- return str(out_path)
-
-def generate_video_thumbnail(video_path: str, output_path: str, time_position: str = "00:00:01") -> str | None:
- """
- Extract a single JPG frame from a video using ffmpeg and write it to output_path.
- Returns output_path on success, or None on failure.
- """
- try:
- os.makedirs(os.path.dirname(output_path), exist_ok=True)
- cmd = [
- "ffmpeg", "-y",
- "-ss", time_position,
- "-i", str(video_path),
- "-vframes", "1",
- "-vf", f"scale={THUMB_SIZE[0]}:-1",
- str(output_path),
- ]
- subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=FFMPEG_THUMB_TIMEOUT)
- return output_path
- except subprocess.CalledProcessError as e:
- print(f"[WARN] Failed to extract video thumbnail for {video_path}: {e}")
- return None
-
-
-def generate_video_thumbnail_mirrored(video_path: str, time_position: str = "00:00:01") -> str | None:
- """
- Mirror the stored (hashed) video path under /images/thumbs/... and save as .jpg.
- """
- images_root = Path("/images").resolve()
- video_path_p = Path(video_path).resolve()
- rel_path = video_path_p.relative_to(images_root) # raises if not under /images
- thumb_path = (images_root / "thumbs" / rel_path).with_suffix(".jpg")
- return generate_video_thumbnail(str(video_path_p), str(thumb_path), time_position=time_position)
-
-
-def transcode_video_to_mp4(input_path: str, output_path: str = None) -> str | None:
- """
- Transcode a video to H.264 MP4 for universal browser playback.
-
- Args:
- input_path: Path to source video file
- output_path: Optional destination path. If None, replaces extension with .mp4
- in a temp location.
-
- Returns:
- Path to transcoded MP4 file on success, None on failure.
- """
- input_p = Path(input_path)
-
- # Determine output path
- if output_path is None:
- # Create temp file with .mp4 extension
- output_path = str(input_p.with_suffix(".mp4"))
- if output_path == input_path:
- # Already .mp4, use temp suffix
- output_path = str(input_p.with_suffix(".transcoded.mp4"))
-
- try:
- os.makedirs(os.path.dirname(output_path), exist_ok=True)
-
- # FFmpeg command for H.264/AAC transcoding with good browser compatibility
- # -movflags +faststart: Enables progressive playback (moov atom at start)
- # -preset medium: Balance between speed and compression
- # -crf 23: Good quality (lower = better, 18-28 typical range)
- # -c:v libx264: H.264 video codec
- # -c:a aac: AAC audio codec
- # -pix_fmt yuv420p: Pixel format for maximum compatibility
- cmd = [
- "ffmpeg", "-y",
- "-i", str(input_path),
- "-c:v", "libx264",
- "-preset", "medium",
- "-crf", "23",
- "-pix_fmt", "yuv420p",
- "-c:a", "aac",
- "-b:a", "128k",
- "-movflags", "+faststart",
- str(output_path),
- ]
-
- print(f"[INFO] Transcoding video: {input_path} -> {output_path}")
- result = subprocess.run(
- cmd,
- check=True,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- timeout=FFMPEG_TRANSCODE_TIMEOUT
- )
- print(f"[INFO] Transcoding complete: {output_path}")
- return output_path
-
- except subprocess.TimeoutExpired:
- print(f"[WARN] Video transcode timed out after {FFMPEG_TRANSCODE_TIMEOUT}s: {input_path}")
- # Clean up partial output
- if os.path.exists(output_path):
- os.remove(output_path)
- return None
- except subprocess.CalledProcessError as e:
- print(f"[WARN] Failed to transcode video {input_path}: {e}")
- if e.stderr:
- print(f"[WARN] FFmpeg stderr: {e.stderr.decode('utf-8', errors='ignore')[:500]}")
- # Clean up partial output
- if os.path.exists(output_path):
- os.remove(output_path)
- return None
- except Exception as e:
- print(f"[WARN] Unexpected error transcoding video {input_path}: {e}")
- if os.path.exists(output_path):
- os.remove(output_path)
- return None
-
-
-def needs_transcode(file_path: str) -> bool:
- """Check if a video file needs transcoding to MP4."""
- ext = Path(file_path).suffix.lower()
- return ext in VIDEO_EXTS_NEED_TRANSCODE
-
-
-def calculate_hash(file_path: str) -> str:
- hash_func = hashlib.sha256()
- with open(file_path, "rb") as f:
- for chunk in iter(lambda: f.read(1024 * 1024), b""): # 1MB chunks work well for big files
- hash_func.update(chunk)
- return hash_func.hexdigest()
-
-
-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:
- with Image.open(file_path) as img:
- return imagehash.phash(img, hash_size=hash_size)
- except Exception:
- # videos or unreadable images land here (phash not applicable)
- return None
-
-
-def find_similar_image(phash, width: int, height: int, existing_phashes, threshold: int = 10):
- """
- 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:
- distance = phash - existing_phash
- if distance <= threshold:
- # 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:
- print(f"[WARN] Failed pHash comparison: {e}")
- 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:
- metadata = {
- "file_size": None,
- "width": None,
- "height": None,
- "format": None,
- "camera_model": None,
- "taken_at": None
- }
-
- metadata["file_size"] = os.path.getsize(file_path)
- ext = os.path.splitext(file_path)[1].lower()
-
- # Get file modification time as fallback for taken_at
- try:
- file_mtime = datetime.fromtimestamp(os.path.getmtime(file_path))
- except Exception:
- file_mtime = None
-
- # Video: use ffprobe
- if ext in (".mp4", ".mov"):
- try:
- cmd = [
- "ffprobe", "-v", "error",
- "-select_streams", "v:0",
- "-show_entries", "stream=width,height",
- "-of", "json", file_path
- ]
- result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=False)
- data = json.loads(result.stdout or "{}")
- stream = data.get("streams", [{}])[0]
- metadata["width"] = stream.get("width")
- metadata["height"] = stream.get("height")
- metadata["format"] = ext.lstrip(".")
- # Use file mtime for videos since they don't have EXIF
- metadata["taken_at"] = file_mtime
- except Exception as e:
- print(f"[WARN] Failed to extract video metadata: {file_path} – {e}")
- return metadata
-
- # Image: PIL + EXIF (best-effort)
- try:
- with Image.open(file_path) as img:
- metadata["width"], metadata["height"] = img.size
- metadata["format"] = (img.format or "").lower()
- except Exception as e:
- mime_type, _ = mimetypes.guess_type(file_path)
- print(f"[WARN] Failed to read image metadata: {file_path} ({e}) ext={ext} mime={mime_type}")
-
- try:
- with open(file_path, "rb") as f:
- tags = exifread.process_file(f, stop_tag="UNDEF", details=False)
- if "EXIF DateTimeOriginal" in tags:
- metadata["taken_at"] = datetime.strptime(str(tags["EXIF DateTimeOriginal"]), "%Y:%m:%d %H:%M:%S")
- if "Image Model" in tags:
- metadata["camera_model"] = str(tags["Image Model"])
- except Exception:
- pass
-
- # Fallback to file modification time if no EXIF date
- if metadata["taken_at"] is None:
- metadata["taken_at"] = file_mtime
-
- return metadata
-
-def _has_alpha(img: Image.Image) -> bool:
- """Return True if the image has any transparency channel."""
- if img.mode in ("RGBA", "LA"):
- return True
- if img.mode == "P" and "transparency" in img.info:
- return True
- return False
-
-def _flatten_to_rgb(img: Image.Image, bg=(0, 0, 0)) -> Image.Image:
- """
- Flatten an RGBA/LA/Palette-with-alpha image onto a solid background,
- returning an RGB image suitable for saving as JPEG.
- """
- # Normalize to RGBA so we can use the alpha channel as a mask
- if img.mode == "P":
- img = img.convert("RGBA")
- if img.mode in ("RGBA", "LA"):
- # Ensure we have an explicit alpha channel as last band
- if img.mode == "LA":
- img = img.convert("RGBA")
- alpha = img.split()[-1] # A channel
- bg_img = Image.new("RGB", img.size, bg)
- bg_img.paste(img, mask=alpha)
- return bg_img
- # No alpha, just convert to RGB
- return img.convert("RGB")
-
-
-# =============================================================================
-# Helpers: Multi-part detection
-# =============================================================================
-
-def is_first_volume(path: str) -> bool:
- """
- Determine if 'path' is the first volume of a multi-part archive.
- We only attempt extraction from the first volume.
- """
- name = os.path.basename(path).lower()
-
- # RAR: .partN.rar (only N==1 is first)
- m = RAR_PART_RE.search(name)
- if m:
- n = m.group(1)
- return n in ("1", "01", "001")
-
- # RAR old-style: first is .rar, subsequent are .r00, .r01...
- if name.endswith(".rar"):
- return True
- if OLD_RAR_VOL_RE.search(name):
- return False
-
- # CBR is typically a single-volume RAR
- if name.endswith(".cbr"):
- return True
-
- # 7z multi-part: .7z.001 is first
- m = SEVENZ_PART_RE.search(name)
- if m:
- n = m.group(1)
- return n in ("1", "01", "001")
- if name.endswith(".7z"):
- return True # single-volume 7z
-
- # ZIP and tarballs: treat as single-volume (we don't include .z01 in our allowlist)
- if name.endswith((".zip", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz")):
- return True
-
- return True
-
-def extract_archive_name(filename: str) -> str:
- """
- Extract a clean archive name from a filename by removing common prefixes.
-
- Handles patterns like:
- - "43387617_attachment_83109081_October 2020 Rewards.rar" -> "October 2020 Rewards"
- - "01_Archive Name.zip" -> "Archive Name"
- - "Archive Name.rar" -> "Archive Name"
-
- Returns the cleaned name without extension.
- """
- import re
- from pathlib import Path
-
- # Remove extension
- stem = Path(filename).stem
-
- # Pattern 1: Gallery-DL style "{id}_attachment_{id}_{name}" or "{id}_{name}"
- # Match one or more numeric_prefix patterns at the start
- cleaned = re.sub(r'^(\d+_)+(attachment_)?(\d+_)?', '', stem)
-
- # Pattern 2: Simple numeric prefix "01_name" or "001_name"
- if cleaned == stem: # No match from pattern 1
- cleaned = re.sub(r'^\d+_', '', stem)
-
- # If we stripped everything or got empty, fall back to original stem
- if not cleaned.strip():
- cleaned = stem
-
- return cleaned.strip()
-
-
-def compute_archive_tag_name(artist: str, archive_filename: str) -> str:
- """
- Generate an archive tag name in the format: 'archive:{artist}:{archive_name}'
-
- Similar to post tags (post:{platform}:{artist}:{id}), archive tags use
- a consistent prefix and colon-separated components.
-
- Examples:
- - artist="InCaseArt", filename="43387617_attachment_83109081_October 2020 Rewards.rar"
- -> "archive:InCaseArt:October 2020 Rewards"
-
- If a tag with the same name already exists, appends a numeric suffix.
- """
- archive_name = extract_archive_name(archive_filename)
- base_tag = f"archive:{artist}:{archive_name}"
-
- # Check if tag already exists
- existing = Tag.query.filter_by(name=base_tag, kind='archive').first()
- if not existing:
- return base_tag
-
- # Tag exists, add numeric suffix
- n = 2
- while True:
- candidate = f"{base_tag} ({n})"
- if not Tag.query.filter_by(name=candidate, kind='archive').first():
- return candidate
- n += 1
-
-
-def compute_next_archive_tag_name(artist: str, width: int = ARCHIVE_NUM_WIDTH) -> str:
- """
- DEPRECATED: Use compute_archive_tag_name() instead.
-
- Return a unique incremental tag name like: 'archive:{artist}/0001', '0002', ...
- - Looks only at Tag(kind='archive') with names starting 'archive:{artist}/'
- - Finds max numeric suffix and returns next number (zero-padded).
- - Ensures no collision even if tags were renamed.
- """
- prefix = f"archive:{artist}/"
- # Pull existing tag names for this artist/prefix
- rows = (Tag.query
- .with_entities(Tag.name)
- .filter(Tag.kind == 'archive',
- Tag.name.like(prefix + '%'))
- .all())
-
- max_n = 0
- for (name,) in rows:
- tail = name[len(prefix):]
- if tail.isdigit():
- try:
- n = int(tail)
- if n > max_n:
- max_n = n
- except Exception:
- pass
-
- # Propose next number and make sure it doesn't already exist
- n = max_n + 1
- while True:
- candidate = f"{prefix}{str(n).zfill(width)}"
- if not Tag.query.filter_by(name=candidate).first():
- return candidate
+# app/utils/image_importer.py
+
+from __future__ import annotations
+
+from datetime import datetime
+from pathlib import Path
+import hashlib
+import json
+import logging
+import mimetypes
+import os
+import re
+import shutil
+import subprocess
+import tempfile
+import uuid
+
+log = logging.getLogger(__name__)
+
+from PIL import Image, ImageOps
+import exifread
+import imagehash
+
+from app import db
+from app.models import ImageRecord, Tag, ArchiveRecord
+
+FFMPEG_THUMB_TIMEOUT = int(os.environ.get("THUMBS_FFMPEG_TIMEOUT", "60")) # seconds
+FFMPEG_TRANSCODE_TIMEOUT = int(os.environ.get("FFMPEG_TRANSCODE_TIMEOUT", "600")) # 10 min default
+Image.MAX_IMAGE_PIXELS = int(os.environ.get("PIL_MAX_IMAGE_PIXELS", "178956970"))
+ARCHIVE_NUM_WIDTH = int(os.environ.get("ARCHIVE_NUM_WIDTH", "4"))
+
+
+# =============================================================================
+# Configuration & Constants
+# =============================================================================
+
+THUMB_SIZE = (400, 400)
+
+# Media and archive extensions we handle
+IMAGE_EXTS = (".jpg", ".jpeg", ".jfif", ".png", ".gif", ".bmp", ".tiff", ".webp")
+VIDEO_EXTS = (".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv")
+VIDEO_EXTS_NEED_TRANSCODE = (".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv") # Non-MP4 formats
+ALLOWED_MEDIA_EXTS = IMAGE_EXTS + VIDEO_EXTS
+ALLOWED_ARCHIVE_EXTS = (
+ ".zip", ".rar", ".cbr", ".7z",
+ ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz"
+)
+
+# Import filter settings (loaded from file or env)
+IMPORT_SETTINGS_PATH = "/import/settings.json"
+
+
+def load_import_settings() -> dict:
+ """
+ Load import filter settings from database, with file and environment fallbacks.
+ Priority: DB settings > file settings > environment variables
+ """
+ # Start with environment defaults
+ defaults = {
+ "min_width": int(os.environ.get("IMPORT_MIN_WIDTH", "0")),
+ "min_height": int(os.environ.get("IMPORT_MIN_HEIGHT", "0")),
+ "skip_transparent": os.environ.get("IMPORT_SKIP_TRANSPARENT", "false").lower() == "true",
+ "transparency_threshold": float(os.environ.get("IMPORT_TRANSPARENCY_THRESHOLD", "0.9")),
+ "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:
+ if os.path.exists(IMPORT_SETTINGS_PATH):
+ with open(IMPORT_SETTINGS_PATH, "r") as f:
+ file_settings = json.load(f)
+ defaults.update(file_settings)
+ except Exception as 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
+
+
+def is_mostly_transparent(file_path: str, threshold: float = 0.9) -> bool:
+ """
+ Check if an image is mostly transparent (> threshold % of pixels are transparent).
+ Returns False for images without alpha channel.
+
+ Args:
+ file_path: Path to the image file
+ threshold: Percentage of pixels that must be transparent (0.0-1.0, default 0.9 = 90%)
+
+ Returns:
+ True if the image has > threshold% transparent pixels
+ """
+ try:
+ with Image.open(file_path) as img:
+ original_mode = img.mode
+
+ # Handle different image modes that may have transparency
+ if original_mode == "RGBA":
+ # Already has alpha channel
+ pass
+ elif original_mode == "LA":
+ # Grayscale with alpha
+ img = img.convert("RGBA")
+ elif original_mode == "P":
+ # Palette mode - check for transparency
+ if "transparency" in img.info:
+ img = img.convert("RGBA")
+ else:
+ return False
+ elif original_mode == "PA":
+ # Palette with alpha
+ img = img.convert("RGBA")
+ else:
+ # RGB, L, etc. - no alpha channel
+ return False
+
+ # Get alpha channel
+ bands = img.split()
+ if len(bands) < 4:
+ return False
+
+ alpha = bands[3] # Alpha is the 4th channel (index 3)
+ alpha_data = alpha.getdata()
+ total_pixels = alpha.size[0] * alpha.size[1]
+
+ if total_pixels == 0:
+ return False
+
+ # Count pixels where alpha < 128 (more than 50% transparent)
+ transparent_count = sum(1 for p in alpha_data if p < 128)
+ transparency_ratio = transparent_count / total_pixels
+
+ return transparency_ratio > threshold
+ except Exception as e:
+ print(f"[WARN] Failed to check transparency for {file_path}: {e}")
+ 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
+RAR_PART_RE = re.compile(r"\.part(\d+)\.rar$", re.IGNORECASE)
+SEVENZ_PART_RE = re.compile(r"\.7z\.(\d{3})$", re.IGNORECASE)
+OLD_RAR_VOL_RE = re.compile(r"\.r\d{2}$", re.IGNORECASE) # .r00, .r01, ...
+
+# Environment configuration (optional)
+ENV_TMPDIR = "ARCHIVE_TMPDIR" # where to extract archives (default: system tmp)
+ENV_MINFREE = "ARCHIVE_MIN_FREE_GB" # min free GB required to start extraction (float; 0 = disabled)
+
+
+# =============================================================================
+# Utilities: Temp space management & free-space guard
+# =============================================================================
+
+def get_tmp_base() -> Path:
+ """Return the base temp directory for archive extraction."""
+ return Path(os.environ.get(ENV_TMPDIR, tempfile.gettempdir())).resolve()
+
+
+def cleanup_stale_tempdirs(prefix: str = "extract_") -> None:
+ """Delete any leftover extraction directories from previous crashes."""
+ base = get_tmp_base()
+ base.mkdir(parents=True, exist_ok=True)
+ for entry in base.glob(f"{prefix}*"):
+ if entry.is_dir():
+ try:
+ shutil.rmtree(entry, ignore_errors=True)
+ except Exception:
+ pass
+
+
+def ensure_free_space_or_raise(path: Path, min_free_gb: float) -> None:
+ """Raise if there is not enough free space at path."""
+ if min_free_gb <= 0:
+ return
+ total, used, free = shutil.disk_usage(str(path))
+ free_gb = free / (1024 ** 3)
+ if free_gb < min_free_gb:
+ raise RuntimeError(
+ f"Insufficient free space at {path}: {free_gb:.1f} GB free "
+ f"(required >= {min_free_gb:.1f} GB)."
+ )
+
+
+# =============================================================================
+# Utilities: External extractors with resilient fallback
+# =============================================================================
+
+class ExtractError(Exception):
+ """Raised when archive extraction fails with all strategies."""
+
+
+def _run_cmd(cmd: list[str]) -> tuple[int, str, str]:
+ p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+ return p.returncode, p.stdout, p.stderr
+
+
+def _count_extracted_files(out_dir: str) -> int:
+ """Count all files (recursively) in the output directory."""
+ count = 0
+ for root, dirs, files in os.walk(out_dir):
+ count += len(files)
+ return count
+
+
+def extract_with_unar(archive_path: str, out_dir: str) -> None:
+ os.makedirs(out_dir, exist_ok=True)
+ rc, out, err = _run_cmd(["unar", "-o", out_dir, archive_path])
+ if rc != 0:
+ # Check if files were actually extracted (partial success)
+ # unar may return non-zero even when most files extracted successfully
+ extracted_files = _count_extracted_files(out_dir)
+ if extracted_files > 0:
+ # Partial extraction - log warning but continue
+ log.warning(f"unar partial extraction (rc={rc}): {extracted_files} files extracted from {archive_path}")
+ return
+ raise ExtractError(f"unar failed (rc={rc})\nSTDOUT:\n{out}\nSTDERR:\n{err}")
+
+
+def extract_with_7z(archive_path: str, out_dir: str) -> None:
+ os.makedirs(out_dir, exist_ok=True)
+ rc, out, err = _run_cmd(["7z", "x", "-y", f"-o{out_dir}", archive_path])
+ if rc != 0:
+ # Check if files were actually extracted (partial success)
+ extracted_files = _count_extracted_files(out_dir)
+ if extracted_files > 0:
+ # Partial extraction - log warning but continue
+ log.warning(f"7z partial extraction (rc={rc}): {extracted_files} files extracted from {archive_path}")
+ return
+ raise ExtractError(f"7z failed (rc={rc})\nSTDOUT:\n{out}\nSTDERR:\n{err}")
+
+
+def looks_password_protected_with_lsar(archive_path: str) -> bool:
+ """Best-effort pre-check for 'Encrypted/Password' in lsar output."""
+ try:
+ rc, out, err = _run_cmd(["lsar", archive_path])
+ txt = (out + "\n" + err).lower()
+ return ("encrypted" in txt) or ("password" in txt)
+ except Exception:
+ return False
+
+
+def extract_archive_resilient(archive_path: str, out_dir: str) -> None:
+ """
+ Prefer unar for RAR/CBR (RAR5-friendly); fallback to 7z.
+ For other formats, try 7z first then unar.
+ Surfaces stderr so logs show CRC/password/permissions issues.
+ """
+ lower = archive_path.lower()
+ errors = []
+
+ # Optional password hint to skip early
+ if lower.endswith((".rar", ".cbr")) and looks_password_protected_with_lsar(archive_path):
+ raise ExtractError("Archive appears password-protected; skipping (no password provided).")
+
+ if lower.endswith((".rar", ".cbr")):
+ order = (extract_with_unar, extract_with_7z)
+ else:
+ order = (extract_with_7z, extract_with_unar)
+
+ for fn in order:
+ try:
+ fn(archive_path, out_dir)
+ return
+ except ExtractError as e:
+ errors.append(str(e))
+
+ raise ExtractError("Both extractors failed:\n" + "\n---\n".join(errors))
+
+
+# =============================================================================
+# Content-addressed storage helpers
+# =============================================================================
+
+def build_hashed_dest_path(target_dir: str, original_name: str, content_hash: str) -> str:
+ """
+ Always produce a content-addressed destination:
+ /__
+ 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
+
+
+# =============================================================================
+# Helpers: Thumbnails, metadata, hashing, similarity
+# =============================================================================
+
+def generate_thumbnail(
+ image_path,
+ size=(400, 400),
+ overwrite=False,
+ prefer_jpeg=False, # False = auto-pick PNG for alpha images; True = always JPEG
+ jpeg_bg=(0, 0, 0), # background used when flattening to JPEG
+):
+ """
+ Save thumbnails mirrored under /images/thumbs/<...>.
+ 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`.
+
+ 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.
+ """
+ images_root = Path("/images").resolve()
+ image_path = Path(image_path).resolve()
+
+ try:
+ rel_path = image_path.relative_to(images_root)
+ except ValueError:
+ raise ValueError(f"{image_path} is not under {images_root}")
+
+ # Base path under /images/thumbs with same subfolders/filename (we may change the suffix)
+ thumb_base = (images_root / "thumbs" / rel_path).with_suffix("") # drop original suffix for control
+
+ # Decide output extension/format
+ with Image.open(image_path) as img:
+ img = ImageOps.exif_transpose(img) # honor camera rotation
+
+ # 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)
+
+ if prefer_jpeg:
+ # Force JPEG for consistency
+ out_path = thumb_base.with_suffix(".jpg")
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ if not overwrite and out_path.exists():
+ return str(out_path)
+ img_rgb = _flatten_to_rgb(img, bg=jpeg_bg)
+ img_rgb.save(out_path, format="JPEG", quality=85, optimize=True, progressive=True)
+ return str(out_path)
+ else:
+ # Auto-pick: PNG for alpha, JPEG otherwise
+ if has_alpha:
+ out_path = thumb_base.with_suffix(".png")
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ if not overwrite and out_path.exists():
+ return str(out_path)
+ # Keep transparency if present
+ # Convert palette to RGBA for reliable PNG writing
+ if img.mode == "P":
+ img = img.convert("RGBA")
+ img.save(out_path, format="PNG", optimize=True)
+ return str(out_path)
+ else:
+ out_path = thumb_base.with_suffix(".jpg")
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ if not overwrite and out_path.exists():
+ return str(out_path)
+ if img.mode != "RGB":
+ img = img.convert("RGB")
+ img.save(out_path, format="JPEG", quality=85, optimize=True, progressive=True)
+ return str(out_path)
+
+def generate_video_thumbnail(video_path: str, output_path: str, time_position: str = "00:00:01") -> str | None:
+ """
+ Extract a single JPG frame from a video using ffmpeg and write it to output_path.
+ Returns output_path on success, or None on failure.
+ """
+ try:
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
+ cmd = [
+ "ffmpeg", "-y",
+ "-ss", time_position,
+ "-i", str(video_path),
+ "-vframes", "1",
+ "-vf", f"scale={THUMB_SIZE[0]}:-1",
+ str(output_path),
+ ]
+ subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=FFMPEG_THUMB_TIMEOUT)
+ return output_path
+ except subprocess.CalledProcessError as e:
+ print(f"[WARN] Failed to extract video thumbnail for {video_path}: {e}")
+ return None
+
+
+def generate_video_thumbnail_mirrored(video_path: str, time_position: str = "00:00:01") -> str | None:
+ """
+ Mirror the stored (hashed) video path under /images/thumbs/... and save as .jpg.
+ """
+ images_root = Path("/images").resolve()
+ video_path_p = Path(video_path).resolve()
+ rel_path = video_path_p.relative_to(images_root) # raises if not under /images
+ thumb_path = (images_root / "thumbs" / rel_path).with_suffix(".jpg")
+ return generate_video_thumbnail(str(video_path_p), str(thumb_path), time_position=time_position)
+
+
+def transcode_video_to_mp4(input_path: str, output_path: str = None) -> str | None:
+ """
+ Transcode a video to H.264 MP4 for universal browser playback.
+
+ Args:
+ input_path: Path to source video file
+ output_path: Optional destination path. If None, replaces extension with .mp4
+ in a temp location.
+
+ Returns:
+ Path to transcoded MP4 file on success, None on failure.
+ """
+ input_p = Path(input_path)
+
+ # Determine output path
+ if output_path is None:
+ # Create temp file with .mp4 extension
+ output_path = str(input_p.with_suffix(".mp4"))
+ if output_path == input_path:
+ # Already .mp4, use temp suffix
+ output_path = str(input_p.with_suffix(".transcoded.mp4"))
+
+ try:
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
+
+ # FFmpeg command for H.264/AAC transcoding with good browser compatibility
+ # -movflags +faststart: Enables progressive playback (moov atom at start)
+ # -preset medium: Balance between speed and compression
+ # -crf 23: Good quality (lower = better, 18-28 typical range)
+ # -c:v libx264: H.264 video codec
+ # -c:a aac: AAC audio codec
+ # -pix_fmt yuv420p: Pixel format for maximum compatibility
+ cmd = [
+ "ffmpeg", "-y",
+ "-i", str(input_path),
+ "-c:v", "libx264",
+ "-preset", "medium",
+ "-crf", "23",
+ "-pix_fmt", "yuv420p",
+ "-c:a", "aac",
+ "-b:a", "128k",
+ "-movflags", "+faststart",
+ str(output_path),
+ ]
+
+ print(f"[INFO] Transcoding video: {input_path} -> {output_path}")
+ result = subprocess.run(
+ cmd,
+ check=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ timeout=FFMPEG_TRANSCODE_TIMEOUT
+ )
+ print(f"[INFO] Transcoding complete: {output_path}")
+ return output_path
+
+ except subprocess.TimeoutExpired:
+ print(f"[WARN] Video transcode timed out after {FFMPEG_TRANSCODE_TIMEOUT}s: {input_path}")
+ # Clean up partial output
+ if os.path.exists(output_path):
+ os.remove(output_path)
+ return None
+ except subprocess.CalledProcessError as e:
+ print(f"[WARN] Failed to transcode video {input_path}: {e}")
+ if e.stderr:
+ print(f"[WARN] FFmpeg stderr: {e.stderr.decode('utf-8', errors='ignore')[:500]}")
+ # Clean up partial output
+ if os.path.exists(output_path):
+ os.remove(output_path)
+ return None
+ except Exception as e:
+ print(f"[WARN] Unexpected error transcoding video {input_path}: {e}")
+ if os.path.exists(output_path):
+ os.remove(output_path)
+ return None
+
+
+def needs_transcode(file_path: str) -> bool:
+ """Check if a video file needs transcoding to MP4."""
+ ext = Path(file_path).suffix.lower()
+ return ext in VIDEO_EXTS_NEED_TRANSCODE
+
+
+def calculate_hash(file_path: str) -> str:
+ hash_func = hashlib.sha256()
+ with open(file_path, "rb") as f:
+ for chunk in iter(lambda: f.read(1024 * 1024), b""): # 1MB chunks work well for big files
+ hash_func.update(chunk)
+ return hash_func.hexdigest()
+
+
+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:
+ with Image.open(file_path) as img:
+ return imagehash.phash(img, hash_size=hash_size)
+ except Exception:
+ # videos or unreadable images land here (phash not applicable)
+ return None
+
+
+def find_similar_image(phash, width: int, height: int, existing_phashes, threshold: int = 10):
+ """
+ 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:
+ distance = phash - existing_phash
+ if distance <= threshold:
+ # 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:
+ print(f"[WARN] Failed pHash comparison: {e}")
+ 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:
+ metadata = {
+ "file_size": None,
+ "width": None,
+ "height": None,
+ "format": None,
+ "camera_model": None,
+ "taken_at": None
+ }
+
+ metadata["file_size"] = os.path.getsize(file_path)
+ ext = os.path.splitext(file_path)[1].lower()
+
+ # Get file modification time as fallback for taken_at
+ try:
+ file_mtime = datetime.fromtimestamp(os.path.getmtime(file_path))
+ except Exception:
+ file_mtime = None
+
+ # Video: use ffprobe
+ if ext in (".mp4", ".mov"):
+ try:
+ cmd = [
+ "ffprobe", "-v", "error",
+ "-select_streams", "v:0",
+ "-show_entries", "stream=width,height",
+ "-of", "json", file_path
+ ]
+ result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=False)
+ data = json.loads(result.stdout or "{}")
+ stream = data.get("streams", [{}])[0]
+ metadata["width"] = stream.get("width")
+ metadata["height"] = stream.get("height")
+ metadata["format"] = ext.lstrip(".")
+ # Use file mtime for videos since they don't have EXIF
+ metadata["taken_at"] = file_mtime
+ except Exception as e:
+ print(f"[WARN] Failed to extract video metadata: {file_path} – {e}")
+ return metadata
+
+ # Image: PIL + EXIF (best-effort)
+ try:
+ with Image.open(file_path) as img:
+ metadata["width"], metadata["height"] = img.size
+ metadata["format"] = (img.format or "").lower()
+ except Exception as e:
+ mime_type, _ = mimetypes.guess_type(file_path)
+ print(f"[WARN] Failed to read image metadata: {file_path} ({e}) ext={ext} mime={mime_type}")
+
+ try:
+ with open(file_path, "rb") as f:
+ tags = exifread.process_file(f, stop_tag="UNDEF", details=False)
+ if "EXIF DateTimeOriginal" in tags:
+ metadata["taken_at"] = datetime.strptime(str(tags["EXIF DateTimeOriginal"]), "%Y:%m:%d %H:%M:%S")
+ if "Image Model" in tags:
+ metadata["camera_model"] = str(tags["Image Model"])
+ except Exception:
+ pass
+
+ # Fallback to file modification time if no EXIF date
+ if metadata["taken_at"] is None:
+ metadata["taken_at"] = file_mtime
+
+ return metadata
+
+def _has_alpha(img: Image.Image) -> bool:
+ """Return True if the image has any transparency channel."""
+ if img.mode in ("RGBA", "LA"):
+ return True
+ if img.mode == "P" and "transparency" in img.info:
+ return True
+ return False
+
+def _flatten_to_rgb(img: Image.Image, bg=(0, 0, 0)) -> Image.Image:
+ """
+ Flatten an RGBA/LA/Palette-with-alpha image onto a solid background,
+ returning an RGB image suitable for saving as JPEG.
+ """
+ # Normalize to RGBA so we can use the alpha channel as a mask
+ if img.mode == "P":
+ img = img.convert("RGBA")
+ if img.mode in ("RGBA", "LA"):
+ # Ensure we have an explicit alpha channel as last band
+ if img.mode == "LA":
+ img = img.convert("RGBA")
+ alpha = img.split()[-1] # A channel
+ bg_img = Image.new("RGB", img.size, bg)
+ bg_img.paste(img, mask=alpha)
+ return bg_img
+ # No alpha, just convert to RGB
+ return img.convert("RGB")
+
+
+# =============================================================================
+# Helpers: Multi-part detection
+# =============================================================================
+
+def is_first_volume(path: str) -> bool:
+ """
+ Determine if 'path' is the first volume of a multi-part archive.
+ We only attempt extraction from the first volume.
+ """
+ name = os.path.basename(path).lower()
+
+ # RAR: .partN.rar (only N==1 is first)
+ m = RAR_PART_RE.search(name)
+ if m:
+ n = m.group(1)
+ return n in ("1", "01", "001")
+
+ # RAR old-style: first is .rar, subsequent are .r00, .r01...
+ if name.endswith(".rar"):
+ return True
+ if OLD_RAR_VOL_RE.search(name):
+ return False
+
+ # CBR is typically a single-volume RAR
+ if name.endswith(".cbr"):
+ return True
+
+ # 7z multi-part: .7z.001 is first
+ m = SEVENZ_PART_RE.search(name)
+ if m:
+ n = m.group(1)
+ return n in ("1", "01", "001")
+ if name.endswith(".7z"):
+ return True # single-volume 7z
+
+ # ZIP and tarballs: treat as single-volume (we don't include .z01 in our allowlist)
+ if name.endswith((".zip", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz")):
+ return True
+
+ return True
+
+def extract_archive_name(filename: str) -> str:
+ """
+ Extract a clean archive name from a filename by removing common prefixes.
+
+ Handles patterns like:
+ - "43387617_attachment_83109081_October 2020 Rewards.rar" -> "October 2020 Rewards"
+ - "01_Archive Name.zip" -> "Archive Name"
+ - "Archive Name.rar" -> "Archive Name"
+
+ Returns the cleaned name without extension.
+ """
+ import re
+ from pathlib import Path
+
+ # Remove extension
+ stem = Path(filename).stem
+
+ # Pattern 1: Gallery-DL style "{id}_attachment_{id}_{name}" or "{id}_{name}"
+ # Match one or more numeric_prefix patterns at the start
+ cleaned = re.sub(r'^(\d+_)+(attachment_)?(\d+_)?', '', stem)
+
+ # Pattern 2: Simple numeric prefix "01_name" or "001_name"
+ if cleaned == stem: # No match from pattern 1
+ cleaned = re.sub(r'^\d+_', '', stem)
+
+ # If we stripped everything or got empty, fall back to original stem
+ if not cleaned.strip():
+ cleaned = stem
+
+ return cleaned.strip()
+
+
+def compute_archive_tag_name(artist: str, archive_filename: str) -> str:
+ """
+ Generate an archive tag name in the format: 'archive:{artist}:{archive_name}'
+
+ Similar to post tags (post:{platform}:{artist}:{id}), archive tags use
+ a consistent prefix and colon-separated components.
+
+ Examples:
+ - artist="InCaseArt", filename="43387617_attachment_83109081_October 2020 Rewards.rar"
+ -> "archive:InCaseArt:October 2020 Rewards"
+
+ If a tag with the same name already exists, appends a numeric suffix.
+ """
+ archive_name = extract_archive_name(archive_filename)
+ base_tag = f"archive:{artist}:{archive_name}"
+
+ # Check if tag already exists
+ existing = Tag.query.filter_by(name=base_tag, kind='archive').first()
+ if not existing:
+ return base_tag
+
+ # Tag exists, add numeric suffix
+ n = 2
+ while True:
+ candidate = f"{base_tag} ({n})"
+ if not Tag.query.filter_by(name=candidate, kind='archive').first():
+ return candidate
+ n += 1
+
+
+def compute_next_archive_tag_name(artist: str, width: int = ARCHIVE_NUM_WIDTH) -> str:
+ """
+ DEPRECATED: Use compute_archive_tag_name() instead.
+
+ Return a unique incremental tag name like: 'archive:{artist}/0001', '0002', ...
+ - Looks only at Tag(kind='archive') with names starting 'archive:{artist}/'
+ - Finds max numeric suffix and returns next number (zero-padded).
+ - Ensures no collision even if tags were renamed.
+ """
+ prefix = f"archive:{artist}/"
+ # Pull existing tag names for this artist/prefix
+ rows = (Tag.query
+ .with_entities(Tag.name)
+ .filter(Tag.kind == 'archive',
+ Tag.name.like(prefix + '%'))
+ .all())
+
+ max_n = 0
+ for (name,) in rows:
+ tail = name[len(prefix):]
+ if tail.isdigit():
+ try:
+ n = int(tail)
+ if n > max_n:
+ max_n = n
+ except Exception:
+ pass
+
+ # Propose next number and make sure it doesn't already exist
+ n = max_n + 1
+ while True:
+ candidate = f"{prefix}{str(n).zfill(width)}"
+ if not Tag.query.filter_by(name=candidate).first():
+ return candidate
n += 1
\ No newline at end of file
diff --git a/app/utils/metadata_enrichment.py b/app/utils/metadata_enrichment.py
index 14b247a..bedcf78 100644
--- a/app/utils/metadata_enrichment.py
+++ b/app/utils/metadata_enrichment.py
@@ -31,6 +31,7 @@ def find_sidecar_json(image_path: str) -> Optional[str]:
Gallery-DL creates JSON files with various naming patterns:
- Patreon: {filename}.json in same directory
- HentaiFoundry: {artist}-{index}-{title}.json (may differ from image filename)
+ - Pixiv: {id}_p{num}.json for image named {id}_{title}_{num}.png
- UUID-based: {uuid}.json where UUID is extracted from filename
Returns the path to the sidecar JSON if found, None otherwise.
@@ -50,7 +51,19 @@ def find_sidecar_json(image_path: str) -> Optional[str]:
if direct_json.exists():
return str(direct_json)
- # Pattern 3: Extract UUID from filename and look for {uuid}.json
+ # Pattern 3: Pixiv naming pattern
+ # Image: {id}_{title}_{num}.png (e.g., "115358744_Koseki Bijou (Bunny)_00.png")
+ # JSON: {id}_p{num}.json (e.g., "115358744_p0.json")
+ # Extract the leading numeric ID and trailing index
+ pixiv_match = re.match(r'^(\d{6,12})_.*_(\d{2})$', stem)
+ if pixiv_match:
+ pixiv_id = pixiv_match.group(1)
+ pixiv_num = int(pixiv_match.group(2)) # Convert "00" to 0
+ pixiv_json = parent_dir / f"{pixiv_id}_p{pixiv_num}.json"
+ if pixiv_json.exists():
+ return str(pixiv_json)
+
+ # Pattern 4: 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)
@@ -60,7 +73,7 @@ def find_sidecar_json(image_path: str) -> Optional[str]:
if uuid_json.exists():
return str(uuid_json)
- # Pattern 4: Just the stem without prefix numbers
+ # Pattern 5: 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:
@@ -68,7 +81,7 @@ def find_sidecar_json(image_path: str) -> Optional[str]:
if alt_json.exists():
return str(alt_json)
- # Pattern 5: Extract numeric ID from filename and find JSON with same ID
+ # Pattern 6: Extract numeric ID from filename and find JSON with same ID
# Handles HentaiFoundry where image is "hentaifoundry_1000220_title.jpg"
# but JSON is "Artist-1000220-title.json"
# Look for a 5-7 digit number that could be a post ID
@@ -165,7 +178,7 @@ def load_sidecar_metadata(json_path: str) -> Optional[dict]:
def extract_platform(metadata: dict) -> Optional[str]:
"""Extract the platform name from metadata."""
- # Check common fields
+ # Check common fields (covers Pixiv which uses "category": "pixiv")
if "category" in metadata:
return metadata["category"].lower()
@@ -179,6 +192,8 @@ def extract_platform(metadata: dict) -> Optional[str]:
return "subscribestar"
if "discord.com" in url:
return "discord"
+ if "pixiv.net" in url or "pximg.net" in url:
+ return "pixiv"
return None
@@ -195,8 +210,13 @@ def extract_artist(metadata: dict) -> Optional[str]:
if "artist" in metadata:
return metadata["artist"]
- # For hentaifoundry
- if "user" in metadata:
+ # For Pixiv - user is an object with name and account fields
+ if "user" in metadata and isinstance(metadata["user"], dict):
+ # Prefer account (username) over display name for consistency
+ return metadata["user"].get("account") or metadata["user"].get("name")
+
+ # For hentaifoundry - user is a string
+ if "user" in metadata and isinstance(metadata["user"], str):
return metadata["user"]
return None
@@ -238,6 +258,7 @@ def extract_description(metadata: dict) -> Optional[str]:
- Patreon: 'content' (may be HTML)
- SubscribeStar: 'content' (may be HTML)
- HentaiFoundry: 'description' (plain text)
+ - Pixiv: 'caption' (plain text or HTML)
"""
# Try description first (HentaiFoundry uses this)
if "description" in metadata:
@@ -245,6 +266,12 @@ def extract_description(metadata: dict) -> Optional[str]:
if desc and isinstance(desc, str) and desc.strip():
return desc.strip()
+ # Try caption (Pixiv uses this)
+ if "caption" in metadata:
+ caption = metadata["caption"]
+ if caption and isinstance(caption, str) and caption.strip():
+ return caption.strip()
+
# Try content (Patreon/SubscribeStar use this)
if "content" in metadata:
content = metadata["content"]
@@ -268,14 +295,23 @@ def extract_source_url(metadata: dict) -> Optional[str]:
- Patreon: 'url' (direct post link)
- SubscribeStar: construct from author_name and post_id
- HentaiFoundry: construct from user and index
+ - Pixiv: construct from id
"""
platform = extract_platform(metadata)
- # Direct URL field (Patreon)
+ # Construct URL for Pixiv (do this first to avoid using the pximg.net image URL)
+ if platform == "pixiv":
+ post_id = metadata.get("id")
+ if post_id:
+ return f"https://www.pixiv.net/artworks/{post_id}"
+
+ # Direct URL field (Patreon) - but not for Pixiv where 'url' is the image URL
if "url" in metadata:
url = metadata["url"]
if url and isinstance(url, str) and url.startswith("http"):
- return url
+ # Skip pximg.net URLs (these are image URLs, not post URLs)
+ if "pximg.net" not in url:
+ return url
# Construct URL for SubscribeStar
if platform == "subscribestar":
@@ -298,6 +334,10 @@ def extract_attachment_count(metadata: dict) -> int:
"""
Extract the number of attachments/images in the post.
"""
+ # Pixiv: use page_count field
+ if "page_count" in metadata:
+ return int(metadata["page_count"])
+
# Patreon: check images array or post_metadata.image_order
if "images" in metadata and isinstance(metadata["images"], list):
return len(metadata["images"])
@@ -319,7 +359,8 @@ 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"]
+ # Pixiv uses create_date, others use published_at, date, etc.
+ date_fields = ["published_at", "create_date", "date", "created_at", "timestamp"]
for field in date_fields:
if field not in metadata:
diff --git a/generate_favicon.py b/generate_favicon.py
new file mode 100644
index 0000000..2145938
--- /dev/null
+++ b/generate_favicon.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""
+Generate favicon files for ImageRepo.
+Creates a simple gallery/image icon in multiple formats and sizes.
+"""
+
+from PIL import Image, ImageDraw
+import os
+
+# Output directory
+STATIC_DIR = os.path.join(os.path.dirname(__file__), 'app', 'static')
+
+# Color scheme (dark theme friendly)
+BG_COLOR = (45, 45, 55) # Dark background
+FRAME_COLOR = (99, 102, 241) # Indigo accent (matches --accent)
+IMAGE_BG = (60, 60, 70) # Slightly lighter for image area
+MOUNTAIN_COLOR = (80, 85, 95) # Mountain silhouette
+SUN_COLOR = (251, 191, 36) # Warm yellow sun
+
+
+def create_favicon_image(size):
+ """Create a gallery icon at the specified size."""
+ img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
+ draw = ImageDraw.Draw(img)
+
+ # Padding and dimensions
+ padding = max(1, size // 16)
+ frame_width = max(1, size // 10)
+ corner_radius = max(2, size // 8)
+
+ # Outer frame (rounded rectangle)
+ frame_box = (padding, padding, size - padding, size - padding)
+ draw.rounded_rectangle(frame_box, radius=corner_radius, fill=FRAME_COLOR)
+
+ # Inner image area
+ inner_padding = padding + frame_width
+ inner_box = (inner_padding, inner_padding, size - inner_padding, size - inner_padding)
+ inner_radius = max(1, corner_radius - frame_width // 2)
+ draw.rounded_rectangle(inner_box, radius=inner_radius, fill=IMAGE_BG)
+
+ # Simple landscape scene inside
+ inner_width = size - 2 * inner_padding
+ inner_height = size - 2 * inner_padding
+
+ if inner_width > 4 and inner_height > 4:
+ # Sun (small circle in upper right)
+ sun_size = max(2, inner_width // 5)
+ sun_x = inner_padding + inner_width - sun_size - max(1, inner_width // 6)
+ sun_y = inner_padding + max(1, inner_height // 6)
+ draw.ellipse(
+ (sun_x, sun_y, sun_x + sun_size, sun_y + sun_size),
+ fill=SUN_COLOR
+ )
+
+ # Mountains (triangular shapes)
+ mountain_base_y = size - inner_padding - max(1, inner_height // 8)
+
+ # Left mountain (taller)
+ peak1_x = inner_padding + inner_width // 3
+ peak1_y = inner_padding + inner_height // 3
+ draw.polygon([
+ (inner_padding, mountain_base_y),
+ (peak1_x, peak1_y),
+ (inner_padding + int(inner_width * 0.6), mountain_base_y)
+ ], fill=MOUNTAIN_COLOR)
+
+ # Right mountain (shorter, overlapping)
+ peak2_x = inner_padding + int(inner_width * 0.7)
+ peak2_y = inner_padding + int(inner_height * 0.5)
+ draw.polygon([
+ (inner_padding + inner_width // 3, mountain_base_y),
+ (peak2_x, peak2_y),
+ (size - inner_padding, mountain_base_y)
+ ], fill=(90, 95, 105)) # Slightly lighter for depth
+
+ return img
+
+
+def create_svg_favicon():
+ """Create an SVG favicon."""
+ svg_content = ''''''
+
+ svg_path = os.path.join(STATIC_DIR, 'favicon.svg')
+ with open(svg_path, 'w') as f:
+ f.write(svg_content)
+ print(f"Created: {svg_path}")
+ return svg_path
+
+
+def main():
+ os.makedirs(STATIC_DIR, exist_ok=True)
+
+ # Generate SVG (scalable, modern browsers)
+ create_svg_favicon()
+
+ # Generate PNG favicons at various sizes
+ sizes = [16, 32, 48, 180, 192, 512]
+ png_images = {}
+
+ for size in sizes:
+ img = create_favicon_image(size)
+ filename = f'favicon-{size}x{size}.png'
+ filepath = os.path.join(STATIC_DIR, filename)
+ img.save(filepath, 'PNG')
+ png_images[size] = img
+ print(f"Created: {filepath}")
+
+ # Create apple-touch-icon (180x180)
+ apple_path = os.path.join(STATIC_DIR, 'apple-touch-icon.png')
+ png_images[180].save(apple_path, 'PNG')
+ print(f"Created: {apple_path}")
+
+ # Create ICO file with multiple sizes (16, 32, 48)
+ ico_path = os.path.join(STATIC_DIR, 'favicon.ico')
+ ico_sizes = [png_images[16], png_images[32], png_images[48]]
+ ico_sizes[0].save(
+ ico_path,
+ format='ICO',
+ sizes=[(16, 16), (32, 32), (48, 48)],
+ append_images=ico_sizes[1:]
+ )
+ print(f"Created: {ico_path}")
+
+ print("\nFavicon generation complete!")
+ print("\nAdd these lines to your layout.html section:")
+ print('''
+
+
+
+''')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/summary.md b/summary.md
index 548a301..57757da 100644
--- a/summary.md
+++ b/summary.md
@@ -4,7 +4,7 @@ A Flask-based image repository with Celery task processing for importing, organi
> **IMPORTANT**: This summary must be kept up to date with any code changes. Update the timestamp below when making modifications.
>
-> **Last Updated**: 2026-02-01 (Settings dashboard: system stats, task filtering, error details)
+> **Last Updated**: 2026-02-04 (Pixiv sidecar support, Tags navbar dropdown, tag search)
---
@@ -422,12 +422,14 @@ Trigger via API: `POST /api/import/trigger` with `deep=true`
### Sidecar Metadata
Gallery-DL JSON files provide:
-- Platform (patreon, hentaifoundry, etc.)
+- Platform (patreon, hentaifoundry, subscribestar, pixiv)
- Artist name
- Post ID and title
- Publication date
- Source URL
+Pixiv-specific: Uses `{id}_p{num}.json` naming pattern, extracts from `user.account`, `create_date`, `caption`, constructs URL as `https://www.pixiv.net/artworks/{id}`
+
---
## Environment Variables